commit ab91b120e676b459cb799567e57cfd4badda8b2e Author: 小煜 <2082529121@qq.com> Date: Tue Mar 3 19:24:57 2026 +0800 feat: 初始化Easy Patch插件及依赖文件 - 添加Blender插件核心文件:__init__.py、ui.py、property.py、preference.py - 添加插件工具模块:g.py、loop.py、generate_loop.py、const.py、op.py - 添加翻译工具:utils/trans.py - 添加PuLP线性规划库及其依赖文件,包括CBC求解器二进制文件 - 添加.gitignore和VSCode配置文件 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a8c2003 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..113000f --- /dev/null +++ b/__init__.py @@ -0,0 +1,45 @@ +bl_info = { + "name": "Easy Patch", + "author": "oimoyu", + "version": (2, 1, 0), + "blender": (3, 2, 0), + "location": "View3D > Sidebar > Easy Patch", + "description": "Easy Patch", + "category": "Object", +} + + +import bpy +from .ui import classes as ui_classes +from .op import classes as op_classes +from .preference import classes as preference_classes +from .property import property_classes, EasyPatchProperty +from .utils.trans import t +from . import const + + +classes = ui_classes + op_classes + property_classes + preference_classes + + +def register(): + # # delete when release + bpy.app.debug = False + + for cls in classes: + bpy.utils.register_class(cls) + + bpy.types.Scene.easy_patch_properties = bpy.props.PointerProperty( + type=EasyPatchProperty + ) + + # for i in bpy.context.preferences.addons: + # print(i) + + # print(bpy.context.preferences.addons["blender-easy-patch-2"].preferences) + + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + del bpy.types.Scene.easy_patch_properties diff --git a/boundary.py b/boundary.py new file mode 100644 index 0000000..49d0faf --- /dev/null +++ b/boundary.py @@ -0,0 +1,535 @@ +from __future__ import annotations +import mathutils +import bmesh +from bpy_extras import view3d_utils +import bpy +import numpy as np + +from . import const +from .utils import functions +from . import g + + +class Boundary: + def __init__(self, start_vertex, end_vertex, path): + self.index = -1 + self.verts = [] + self.path = path + self.start_vertex = start_vertex + self.end_vertex = end_vertex + + self.temp_length = 3 # for uncreated boundary + + self.mid_co = mathutils.Vector((0, 0, 0)) + self.solid_path = [] + self.is_editable = True + self.is_hover = False + + self.is_solid_start = ( + not not start_vertex + and start_vertex not in self.get_boundaries_verts_except_self() + ) + self.is_solid_end = None + + @classmethod + def create_temp(cls, start_vertex): + instance = cls( + start_vertex, + None, + [], + ) + return instance + + @property + def length(self): + if self.edges: + return len(self.edges) + else: + return self.temp_length + + @length.setter + def length(self, length): + # 固化边 + if not self.is_editable: + return + if length < 1: + return + + # TODO: 下面两个暂不用考虑,两个boundary为同一个loop并且boundary都为1了,则冲突了 + + # 建成和非建成改的长度不一样 + if self.edges: + # TODO:特殊情况,两个boundary且都为1的时候冲突了 + # if ( + # length == 1 + # and len(self.op_instance.patch.boundaries) == 2 + # and any( + # [ + # boundary.length == 1 + # for boundary in self.op_instance.patch.boundaries + # ] + # ) + # ): + # return + + # 特殊情况,已建成的边,如果头尾都吸附到了某个edge的两个顶点上(并且不是吸附路径),如果这个时候改变分段为1,则会报错创建已存在的边 + if length == 1: + start_vertex_related_edges = self.start_vertex.link_edges + for start_vertex_related_edge in start_vertex_related_edges: + if self.end_vertex in start_vertex_related_edge.verts: + return + + self.change_length(length) + else: + # # TODO:特殊情况,两个边且都为1的时候 + # if ( + # length == 1 + # and len(self.op_instance.patch.boundaries) == 1 + # and self.op_instance.patch.boundaries[0].length == 1 + # ): + # return + + # 特殊情况,未建成的边,如果头尾都吸附到了某个edge的两个顶点上(并且不是吸附路径),如果这个时候改变分段为1,则会报错创建已存在的边,强制约束不能小于1 + if length <= 1: + return + + self.temp_length = length + + def get_actual_verts_co_and_path(self): + # 第一个是考虑了temp_path,第二个是真实的path + if self.solid_path: + path = self.solid_path + vertex_co_along_path = path + else: + path = self.path + vertex_co_along_path, _ = self.vertices_co_along_path() + return vertex_co_along_path, path + + def change_length(self, length): + self.remove_mesh() + self.create_mesh(length) + + # self.op_instance.patch.update_after_boundary_change() + functions.on_change_boundary_length(self) + + def append_path_from_2d( + self, + context, + co_2d, + is_snap_surface=True, + is_snap_opposite=False, + ): + # 獲取base_co + if len(self.path) != 0: + base_co = self.path[-1] + else: + if self.start_vertex: + # vertex.co获取的是局部坐标 + start_vertex = g.obj.matrix_world @ self.start_vertex.co + base_co = start_vertex + self.path.append(start_vertex) + else: + base_co = (0, 0, 0) + + region = context.region + rv3d = context.region_data + + # 阈值 + if self.path: + path_last_co_2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, self.path[-1] + ) # 视图外会返回None + if not path_last_co_2d: + return + if ( + path_last_co_2d - mathutils.Vector(co_2d) + ).length < const.draw_path_threshold: + # print("太小不记录") + return + + co_3d = [] + last_co = mathutils.Vector(base_co) + + # Get the ray origin and direction + ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, co_2d) + ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, co_2d) + + hit = None + if is_snap_surface: + snap_bvh_trees = g.snap_bvh_trees + for snap_bvh_tree in snap_bvh_trees: + location, normal, index, distance = snap_bvh_tree.ray_cast( + ray_origin, ray_direction + ) + if location: + hit = True + break + + if hit: + if ( + is_snap_opposite or normal.dot(ray_direction.normalized()) < 0 + ): # 点乘大于0,反向 + co_3d = location + + if not co_3d: + # Project base point + plane_center = last_co + plane_normal = ray_direction + + intersection_point = mathutils.geometry.intersect_line_plane( + ray_origin, ray_origin + ray_direction, plane_center, plane_normal + ) + if intersection_point: + co_3d = intersection_point + + self.path.append(co_3d) + return + + def create_mesh(self, length): + if self.solid_path: + self.is_editable = False + # self.edges 和self.verts 已在update_solid中定义,并且不用创建实体 + + else: + vertices_co_along_path_3d, self.verts_correspond_path_idxs = ( + self.vertices_co_along_path(length) + ) + + # create vert + vertices_along_path = [] + for i in range(len(vertices_co_along_path_3d)): + coord = vertices_co_along_path_3d[i] + if i == 0: + # 开头 + if self.start_vertex: + vertices_along_path.append(self.start_vertex) + else: + vertex = functions.create_temp_vertex(coord) + vertices_along_path.append(vertex) + self.start_vertex = vertex + + elif i == len(vertices_co_along_path_3d) - 1: + # 中间 + if self.end_vertex: + vertices_along_path.append(self.end_vertex) + else: + vertex = functions.create_temp_vertex(coord) + vertices_along_path.append(vertex) + self.end_vertex = vertex + else: + # 结尾 + vertex = functions.create_temp_vertex(coord) + vertices_along_path.append(vertex) + + # create edge + boundary_edges = [] + for i in range(len(vertices_along_path) - 1): + edge = functions.create_temp_edge( + vertices_along_path[i], vertices_along_path[i + 1] + ) + boundary_edges.append(edge) + bmesh.update_edit_mesh(g.obj_me) + + self.edges = boundary_edges # ordered edges from start vert + self.verts = vertices_along_path + + self.update_mid_co() + + @functions.set_block_before_done + def done_from_temp(self, end_vertex): + if ( + len(self.path) < 3 + ): # path代表鼠标路径,即使吸附上了solid边,这个也是鼠标路径 + functions.log("too short, cancel") + return + + self.end_vertex = end_vertex + self.is_solid_end = ( + not not end_vertex + and end_vertex not in self.get_boundaries_verts_except_self() + ) + + if ( + self.start_vertex + and self.end_vertex + and self.start_vertex == self.end_vertex + ): + functions.log("start and end same, cancel") + return + + # 开始处理切分↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ + # 处理切分 + # boundary被切分,被切分的boundary的相关的loop要先删pattern及其网格,每切分一个boundary,boundary会被删除1条,新建3条,然后重建loop,然后相关的loop(即新建的boundary,被切分成2个的boundary)要重建网格 + + # 截断了另外的boundary,需要把那些boundary拆分并重建,两个可能:1.开始或结束点在boundary上 2.开启了自动截断模式(笔记,自动截断的情况,获取截断点的坐标,分割path,注意要先创建截断点vert(这里单独写个函数),把他赋予到对应boundary的start和end,再执行create_mesh) + mid_verts_to_boundary = ( + {} + ) # 建立中间点(即除了开始和结束)到boundary的映射。考虑不是每次都建立,不然滚轮的时候会频繁创建(开销很小,不考虑) + for b in g.boundaries: + if b == self: # 排除自身 + continue + for v in b.verts: + if v == b.start_vertex or v == b.end_vertex: + continue + + # # 理论上来说中间点和boundary是一一对应的,不可能有重复的情况 + # if v in mid_verts_to_boundary: + # raise "mid vert duplicated" + + mid_verts_to_boundary[v] = b + + # TODO: 暂时无法处理两个切分点都在同一个boundary的中间点上,返回None + # TODO: 暂时无法处理两个boundary的情况 + # 下面这个约束同时过滤了上面这两个,实际上这两个issue要同时解决才能应用 + for b in g.boundaries: + if b == self: # 跳过自身(没必要,因为这个都没创建) + continue + if self.start_vertex in b.verts and self.end_vertex in b.verts: + functions.log("2 boundary case, cancel") + return + + # 被切割的boundary要先删pattern的mesh,等切割完了,再全部recreate + to_be_seperate_boundary_by_end = None + to_be_seperate_boundary_by_end_related_loops = [] + if self.end_vertex in mid_verts_to_boundary: + to_be_seperate_boundary_by_end = mid_verts_to_boundary[self.end_vertex] + for loop in functions.get_loops_from_boundary( + to_be_seperate_boundary_by_end + ): + # 删pattern + loop.remove_pattern() + to_be_seperate_boundary_by_end_related_loops.append(loop) + + to_be_seperate_boundary_by_start = None + to_be_seperate_boundary_by_start_related_loops = [] + if self.start_vertex in mid_verts_to_boundary: + to_be_seperate_boundary_by_start = mid_verts_to_boundary[self.start_vertex] + for loop in functions.get_loops_from_boundary( + to_be_seperate_boundary_by_start + ): + # 删pattern + loop.remove_pattern() + to_be_seperate_boundary_by_start_related_loops.append(loop) + + # 从全局变量删loop + to_be_seperate_boundary_related_loops = set( + ( + to_be_seperate_boundary_by_end_related_loops + + to_be_seperate_boundary_by_start_related_loops + ) + ) # 取并集,因为有可能会有同一个loop的情况 + for ( + to_be_seperate_boundary_related_loop + ) in to_be_seperate_boundary_related_loops: + g.loops.remove(to_be_seperate_boundary_related_loop) + + # 从全局变量删boundary + if to_be_seperate_boundary_by_end: + g.boundaries.remove(to_be_seperate_boundary_by_end) + if to_be_seperate_boundary_by_start: + g.boundaries.remove(to_be_seperate_boundary_by_start) + + # 切分 + if to_be_seperate_boundary_by_end: + need_to_be_cut_boundary = mid_verts_to_boundary[self.end_vertex] + seperate_boundary_1, seperate_boundary_2 = seperate_boundary_from_vert( + need_to_be_cut_boundary, self.end_vertex + ) + # 切分为两个的boundary加进全局变量 + for i in [seperate_boundary_1, seperate_boundary_2]: + # 重建boundary网格 + i.create_mesh(3) + # 附加到全局变量 + g.boundaries.add(i) + if to_be_seperate_boundary_by_start: + need_to_be_cut_boundary = mid_verts_to_boundary[self.start_vertex] + seperate_boundary_1, seperate_boundary_2 = seperate_boundary_from_vert( + need_to_be_cut_boundary, self.start_vertex + ) + # 切分为两个的boundary加进全局变量 + for i in [seperate_boundary_1, seperate_boundary_2]: + # 重建boundary网格 + i.create_mesh(3) + # 附加到全局变量 + g.boundaries.add(i) + + # 重建loop和pattern在外部进行了,因为是删loop而不是改loop所以可以自动识别到 + # 处理切分完毕↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ + + # 如果开始结束都在某个loop的控制点中,则要把那个loop删掉,如:正方形,对角切 + for loop in g.loops: + controll_verts = loop.controll_verts + if ( + self.start_vertex in controll_verts + and self.end_vertex in controll_verts + ): + g.loops.remove(loop) + loop.remove_pattern() + + # 创建网格前,处理吸附到中轴线 + if self.is_editable and g.is_drawing_along_mirror: + functions.process_path_to_mirror_axis(self.path) + + self.create_mesh(self.length) + g.boundaries.add(self) + + g.is_redraw_boundary = True + + return self + + def remove_mesh( + self, exclude_verts=[], is_force_remove_edges=False, is_remove_start_end=False + ): + if is_force_remove_edges: + functions.remove_bm_edges( + g.obj_bm, self.edges + ) # using bmesh.ops.delete edges will also delete the island vert + + if len(self.edges) == 1: + # 特殊情况, + functions.remove_bm_edges( + g.obj_bm, [self.edges[0]] + ) # using bmesh.ops.delete edges will also delete the island vert + + if len(self.edges) > 1: + if is_remove_start_end: + # 删除网格,包括开始和结束顶点,用于用户在modal的删除 + boundaries_verts_except_self = self.get_boundaries_verts_except_self() + to_be_delete_verts = [] + remaining_verts = [] + for vert in self.verts_generator(): + if vert in exclude_verts: + continue # 排除 exclude_verts 中的顶点 + + if self.is_solid_start and vert == self.start_vertex: + remaining_verts.append( + vert + ) # 如果是 solid_start, 跳过start_vertex + continue + if self.is_solid_end and vert == self.end_vertex: + remaining_verts.append(vert) # 如果是 solid_end, 跳过end_vertex + continue + # 并且这个vert不能出现在其他boundary中 + if vert in boundaries_verts_except_self: + remaining_verts.append(vert) + continue + + to_be_delete_verts.append(vert) + functions.delete_bm_verts(g.obj_bm, to_be_delete_verts) + + # 即使经过上面的条件,还是可能漏,如:三角形,外部画一根截断一个边,先删外部的那一根,然后删被截断的下面那根,然后删上面那根,中间会留一个顶点,因为一开始就把他当成solid_vert看了,但是随着boundary被删除,他就不是了 + to_be_delete_verts = [] + for vert in remaining_verts: + if len(vert.link_edges) == 0: + to_be_delete_verts.append(vert) + functions.delete_bm_verts(g.obj_bm, to_be_delete_verts) + + else: + # 删除网格,除了开始和结束顶点,用于改变长度时的删除网格 + verts = [ + vert + for vert in self.verts_generator() + if vert != self.start_vertex + and vert != self.end_vertex + and vert not in exclude_verts + ] + functions.delete_bm_verts(g.obj_bm, verts) + + self.edges = [] + bmesh.update_edit_mesh(g.obj_me) + + def get_boundaries_verts_except_self(self): + boundaries_verts_except_self = set() + for b in g.boundaries: + if b == self: + continue + boundaries_verts_except_self.update(b.verts) + return boundaries_verts_except_self + + def verts_generator(self): + # # find first edge + # start_vertex = self.start_vertex + # yield start_vertex + # for edge in self.edges: + # end_vertex = edge.other_vert(start_vertex) + # yield end_vertex + # start_vertex = end_vertex + + for i in self.verts: # 搞不懂为什么一开始写成上面这样 + yield i + + @property + def vertices_co(self): + return [temp.co for temp in list(self.verts_generator())] + + def vertices_co_along_path(self, edge_length=None): + if edge_length is None: + edge_length = self.temp_length + return functions.uniform_vertices_co_along_path(self.path, edge_length - 1) + + def update_mid_co(self): + midpoint_of_path = functions.get_midpoint_of_path(g.obj_bm, self.edges) + + self.mid_co = g.obj.matrix_world @ midpoint_of_path + return + + @functions.set_block_before_done + def delete(self): + # boundary删除,loop可能会被删除,loop被删pattern也会被删 + related_loops = functions.get_loops_from_boundary(self) + + # 删除网格 + for related_loop in related_loops: + related_loop.remove_pattern() + + if not self.solid_path: + self.remove_mesh(is_remove_start_end=True) + + # 从全局变量中删除loop和boundary + g.boundaries.remove(self) + for related_loop in related_loops: + g.loops.remove(related_loop) + + g.is_redraw_boundary = True + g.is_redraw_pattern = True + + return + + +def seperate_from_path_idxs(path_idxs): + # 从多个 + pass + + +def seperate_boundary_from_vert(boundary, seperate_vert): + # 从boundary已有的顶点,拆分boundary + # 如果path不够长,则只有头尾直线 + + # # 首先要删除boundaries列表(外部删了) + # g.boundaries.remove(boundary) + + seperate_vert_co_world = g.obj.matrix_world @ seperate_vert.co + + # 删除boundary网格(不包括开始和结束点和vert) + boundary.remove_mesh(exclude_verts=[seperate_vert], is_force_remove_edges=True) + + # 拆分 + ori_start_vert = boundary.start_vertex + ori_end_vert = boundary.end_vertex + + # 通过verts_correspond_path_idxs,来获取已有顶点到path索引的映射,因为path是不均匀的,所以要在计算均匀顶点的时候顺便计算 + vert_idx_in_verts = boundary.verts.index(seperate_vert) + vert_idx_in_path = boundary.verts_correspond_path_idxs[ + vert_idx_in_verts + ] # 如果path 10个,则范围是0~9,tou是0,尾是9,中间是0~8 + if len(boundary.path) < vert_idx_in_path + 2: # 理论不会发生 + raise f"vert_idx_in_path exceed path length: {len(boundary.path)} {vert_idx_in_path}" + path_1 = boundary.path[0 : vert_idx_in_path + 1] + [seperate_vert_co_world] + path_2 = [seperate_vert_co_world] + boundary.path[ + vert_idx_in_path + 1 : len(boundary.path) + ] + + boundary_1 = Boundary(ori_start_vert, seperate_vert, path_1) + boundary_2 = Boundary(seperate_vert, ori_end_vert, path_2) + + return boundary_1, boundary_2 diff --git a/const.py b/const.py new file mode 100644 index 0000000..8ce5bc9 --- /dev/null +++ b/const.py @@ -0,0 +1,8 @@ +addon_prefix = "easy_patch" + +draw_path_threshold = 2 # 绘制路径的最小间隔 +hover_threshold = 20 # 触发鼠标悬浮的最小间隔 + +mouse_move_threshold = 2 # px + +snap_vertex_threshold = 30 diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..a5ca747 --- /dev/null +++ b/debug.log @@ -0,0 +1,2 @@ +[0303/180138.538:INFO:gin\isolate_holder.cc:165] SetPartitionAllocOomCallback and RegisterIsolateHolder +[0303/180155.896:INFO:gin\isolate_holder.cc:165] SetPartitionAllocOomCallback and RegisterIsolateHolder diff --git a/draw.py b/draw.py new file mode 100644 index 0000000..419d782 --- /dev/null +++ b/draw.py @@ -0,0 +1,785 @@ +from __future__ import annotations +import bpy +import blf +import gpu +import bmesh +from bpy_extras import view3d_utils +from . import g +from gpu_extras.batch import batch_for_shader +from gpu_extras.presets import draw_circle_2d +from .utils import functions +import random +from .utils.trans import t +import mathutils +import itertools +import traceback +import time + +from .utils import functions +from . import const + +if bpy.app.version[0] == 3 or bpy.app.version[0] == 4: + import bgl + +def draw_desc(self, context): + prefs = functions.get_preferences() + + if prefs.desc_position_y == 100 or prefs.desc_position_x == 100: + return + + font_id = 0 + + region = context.region + width, height = region.width, region.height + + blf.color(font_id, 1, 1, 1, 0.6) + + font_size = self.get_font_size() + self.set_font_size(font_size) + # base_x, base_y = width / 15, height * 7 / 8 + base_x = width * prefs.desc_position_x / 100 + base_y = height * (100 - prefs.desc_position_y) / 100 + + def position_generator(base_x, base_y, line_height): + while True: + blf.position(font_id, base_x, base_y, 0) + yield + base_y -= line_height + + pos_gen = position_generator(base_x, base_y, font_size) + + if g.mode == g.Mode.NORMAL: + next(pos_gen) + blf.draw(font_id, f"{t('current_status')}: {t('NORMAL')}") + + next(pos_gen) + next(pos_gen) + blf.draw(font_id, f"{t('click_drag_to_draw_line')}") + + next(pos_gen) + blf.draw(font_id, f"{t('hold_ctrl')} - {t('snap_vert')}") + next(pos_gen) + blf.draw( + font_id, f"{t('hold_ctrl')}+{t('hold_shift')} - {t('snap_exist_path')}" + ) + next(pos_gen) + blf.draw( + font_id, + f"M {t('key')} - {t('draw_on_axis_of_symmetry')}{'('+t('persist')+')' if g.is_drawing_along_mirror_persist else ''}: {t('enable') if g.is_drawing_along_mirror else t('disable')}", + ) + + next(pos_gen) + blf.draw( + font_id, + f"B {t('key')} - {t('skip_no_solution_pattern')}: {t('enable') if g.is_auto_search_pattern else t('disable')}", + ) + + next(pos_gen) + blf.draw( + font_id, + f"S {t('key')} - {t('snap_surface')}: {t('enable') if g.is_snap_surface else t('disable')}", + ) + + if g.mode == g.Mode.NORMAL: + next(pos_gen) + blf.draw( + font_id, + f"V {t('key')} - {t('snap_opposite')}: {t('enable') if g.is_snap_opposite else t('disable')}", + ) + + if g.hovering_boundary: + next(pos_gen) + blf.draw(font_id, f"X {t('key')} - {t('delete_highlight_edge')}") + + if g.hovering_loop: + loop = g.hovering_loop + next(pos_gen) + next(pos_gen) + blf.draw(font_id, f"Ctrl+{t('scroll')} - {t('change_segment')}") + next(pos_gen) + blf.draw(font_id, f"Shift+{t('scroll')} - {t('change_padding')}") + next(pos_gen) + blf.draw(font_id, f"T {t('key')} - {t('switch_pattern')}") + next(pos_gen) + blf.draw(font_id, f"R {t('key')} - {t('rotate_pattern')}") + + if g.hovering_loop.pattern: + next(pos_gen) + blf.draw( + font_id, + f"Shift + 1,2,3 - {t('smooth_mesh')}", + ) + next(pos_gen) + blf.draw( + font_id, + f"i {t('key')} - {t('faces_normal_toward_viewport')}", + ) + + if g.hovering_loop.pattern: + pattern = loop.pattern + next(pos_gen) + next(pos_gen) + blf.draw( + font_id, + f"{t('solver_solution')}: {[int(i) for i in pattern.solution]}", + ) + next(pos_gen) + blf.draw(font_id, f"{t('current_para')}:") + next(pos_gen) + blf.draw(font_id, f"{t('pattern')}: {pattern.name}") + next(pos_gen) + blf.draw( + font_id, + f"{t('rotation')}: {pattern.rotation}", + ) + else: + next(pos_gen) + next(pos_gen) + blf.color(font_id, 1, 0.5, 0.5, 0.6) + blf.draw( + font_id, + f"{t('solver_solution')}: {loop.solver_msg}", + ) + + # + if ( + any([i for i in loop.solver_constraint_list if i is not None]) + or loop.solver_constraint_rotation is not None + or loop.solver_constraint_pattern + ): + blf.color(font_id, 1, 0.5, 0.5, 0.6) + + next(pos_gen) + next(pos_gen) + blf.draw( + font_id, + f"{t('solver_constraint')}:", + ) + + next(pos_gen) + blf.draw( + font_id, + f"{t('pattern')}: {loop.solver_constraint_pattern.name}", + ) + next(pos_gen) + blf.draw( + font_id, + f"{t('rotation')}: {loop.solver_constraint_rotation}", + ) + next(pos_gen) + blf.draw( + font_id, + f"{t('var_constraint')}: {[i if i is not None else '' for i in loop.solver_constraint_list]}", + ) + + next(pos_gen) + blf.draw( + font_id, + f"C {t('key')} {t('clear_constraint')}", + ) + next(pos_gen) + blf.draw( + font_id, + f"Shift + C {t('only_clear_var')}", + ) + next(pos_gen) + blf.draw( + font_id, + f"Ctrl + C {t('only_clear_rotation')}", + ) + + elif g.mode == g.Mode.DRAWING: + next(pos_gen) + blf.draw(font_id, f"{t('current_status')}: {t('DRAWING')}") + next(pos_gen) + next(pos_gen) + + next(pos_gen) + blf.draw(font_id, f"{t('right_click')}: {t('cancel_this_draw')}") + + next(pos_gen) + blf.draw(font_id, f"{t('left_click_release')}: {t('apply_this_draw')}") + + next(pos_gen) + blf.draw(font_id, f"{t('scroll')} - {t('change_segment')}") + + elif g.mode == g.Mode.MERGE_BOUNDARY: + next(pos_gen) + blf.draw(font_id, f"{t('current_status')}: {t('MERGE_BOUNDARY')}") + next(pos_gen) + next(pos_gen) + + blf.color(font_id, 1, 1, 1, 0.6) + next(pos_gen) + next(pos_gen) + next(pos_gen) + blf.draw(font_id, f"{t('enter')} - {t('apply_mesh')}") + next(pos_gen) + blf.draw(font_id, f"ESC - {t('quit')}") + + +def draw_snap_vertex(self, context): + if ( + not g.drawing_verts_snap_vertex + or g.drawing_verts_snap_vertex not in g.obj_bm.verts + ): + return + + location_2d = view3d_utils.location_3d_to_region_2d( + context.region, + context.region_data, + g.obj.matrix_world @ g.drawing_verts_snap_vertex.co, + ) + if not location_2d: + return + + if location_2d: + font_size = self.get_font_size() // 2 + self.set_line_width(1) + draw_circle_2d(location_2d, (1, 1, 1, 1), font_size) + + +def draw_hovering_loop_3d(self): + if g.mode != g.Mode.NORMAL: + return + + hovering_loop = g.hovering_loop + if not hovering_loop: + return + + verts = hovering_loop.verts + mid_co = sum((g.obj.matrix_world @ v.co for v in verts), mathutils.Vector()) / len( + verts + ) + + coords = [mid_co] + [g.obj.matrix_world @ v.co for v in verts] + indices = [] + + for i in range(len(verts)): + if i == len(verts) - 1: + indices.append((0, i + 1, 1)) + else: + indices.append((0, i + 1, i + 2)) + + shader = self.shader_3d_uniform_color + gpu.state.blend_set("ALPHA") + batch = batch_for_shader(shader, "TRIS", {"pos": coords}, indices=indices) + shader.bind() + shader.uniform_float("color", (0, 0.6, 0, 0.3)) + batch.draw(shader) + + +def draw_hovering_boundary(self): + if g.mode != g.Mode.NORMAL: + return + + hovering_boundary = g.hovering_boundary + if not hovering_boundary: + return + + path = [g.obj.matrix_world @ v.co for v in hovering_boundary.verts] + line_co_pairs = [] + for i in range(len(path) - 1): + line_co_pairs.append((path[i], path[i + 1])) + draw_lines_3d(self, line_co_pairs, (1, 1, 1, 1), 5) + + +def draw_loop(self): + for loop in g.loops: + for idx, (boundary, is_forward) in enumerate(loop.b_and_d_list): + + region = bpy.context.region + rv3d = bpy.context.region_data + coord_2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, boundary.mid_co + ) + if coord_2d is not None: + text = str(idx) + if not is_forward: + text += " R" + + # 绘制索引 + blf.size(0, 50) + blf.position(0, coord_2d[0], coord_2d[1], 0) + blf.color(0, 0, 1, 0, 1) + blf.draw(0, text) + + +def draw_temp_patch_boundary(self): + boundary = g.drawing_path_boundary + if not boundary: + return + + # draw path + vertex_co_along_path, path = boundary.get_actual_verts_co_and_path() + + shader = self.shader_3d_uniform_color + gpu.state.blend_set("ALPHA") + self.set_line_width(5) + batch = batch_for_shader(shader, "LINE_STRIP", {"pos": path}) + shader.bind() + shader.uniform_float("color", (0.0, 0.0, 1, 0.5)) + batch.draw(shader) + + # draw vertex + # vertex_co_along_path = patch_boundary.vertices_co_along_path() + for vertex_co in vertex_co_along_path: + # TODO: vulkun 用这个shader的时候,gpu.state.point_size_set 失效了,等待修复或别的方案。因为新shader POINT_UNIFORM_COLOR,只能画2d顶点 + batch = batch_for_shader(shader, "POINTS", {"pos": [vertex_co]}) + shader.uniform_float("color", (1, 1, 1, 1)) + batch.draw(shader) + + +def draw_boundaries_3d(self): + if not g.boundaries: + return + + # edges + if g.is_redraw_boundary or not g.last_boundary_data: + draw_boundary_data_list = [] + for boundary in g.boundaries: + line_color = functions.hsv_to_rgb( + functions.stable_random_from_obj(boundary), 1, 1, 1 + ) + boundaries_verts_co = boundary.vertices_co + boundaries_verts_co = [g.obj.matrix_world @ i for i in boundaries_verts_co] + + line_co_pairs = [] + vert_coords = boundaries_verts_co + for i in range(len(boundaries_verts_co) - 1): + line_co_pair = (boundaries_verts_co[i], boundaries_verts_co[i + 1]) + + line_co_pairs.append(line_co_pair) + + draw_boundary_data_list.append((line_co_pairs, line_color, vert_coords)) + + draw_lines_3d(self, line_co_pairs, line_color, line_width=2.5) + draw_points_3d(self, vert_coords, (1, 1, 1, 1), point_size=5) + + g.last_boundary_data = draw_boundary_data_list + g.is_redraw_boundary = False + else: + for boundary_data in g.last_boundary_data: + line_co_pairs, line_color, vert_coords = boundary_data + draw_lines_3d(self, line_co_pairs, line_color, line_width=2.5) + draw_points_3d(self, vert_coords, (1, 1, 1, 1), point_size=5) + + +def draw_boundaries_text_2d(self): + font_id = 0 + font_size = self.get_font_size() + + context = bpy.context + rv3d = context.region_data + region = context.region + + # 绘制边数 + for boundary in g.boundaries: + coord_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, boundary.mid_co) + if coord_2d is None: + return + + x, y = coord_2d + blf.position(font_id, x, y, 0) + blf.color(font_id, 1, 1, 1, 1) + self.set_font_size(font_size) + blf.draw(font_id, f"{boundary.length}") + + # 绘制建议文本 + for loop in g.loops: + for i, boundary in enumerate(loop.boundaries): + boundary_mid_co_2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, boundary.mid_co + ) + loop_mid_co_2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, loop.mid_co + ) + + offset = mathutils.Vector((0, 0)) + if ( + boundary_mid_co_2d.x <= loop_mid_co_2d.x + and boundary_mid_co_2d.y <= loop_mid_co_2d.y + ): + result = mathutils.Vector((1, 1)) # a在b的左下角 + elif ( + boundary_mid_co_2d.x >= loop_mid_co_2d.x + and boundary_mid_co_2d.y <= loop_mid_co_2d.y + ): + result = mathutils.Vector((-3, 1)) # a在b的右下角 + elif ( + boundary_mid_co_2d.x <= loop_mid_co_2d.x + and boundary_mid_co_2d.y >= loop_mid_co_2d.y + ): + result = mathutils.Vector((1, -1)) # a在b的左上角 + elif ( + boundary_mid_co_2d.x >= loop_mid_co_2d.x + and boundary_mid_co_2d.y >= loop_mid_co_2d.y + ): + result = mathutils.Vector((-3, -1)) # a在b的右上角 + + offset_value = 20 + offset = result * offset_value + + # 建议文本 + suggest_text = None + if loop.suggest_solution: + pos_value = loop.suggest_solution[i] + neg_value = loop.suggest_solution[i + len(loop.boundaries)] + if pos_value > 0: + suggest_text = f"+{int(pos_value)}" + if neg_value > 0: + suggest_text = f"{-int(neg_value)}" + + suggest_text_co_3d = boundary.mid_co + suggest_text_co_2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, suggest_text_co_3d + ) + if not suggest_text_co_2d: + continue + suggest_text_co_2d = suggest_text_co_2d + offset + + constraint_text = None + constraint_value = loop.solver_constraint_list[i] + if loop.solver_constraint_list[i] is not None: + constraint_text = f"{t('padding')}:{int(constraint_value)}" + draw_patch_boundary_text( + self, + suggest_text_co_2d, + suggest_text=suggest_text, + constraint_text=constraint_text, + ) + + +def draw_patch_pattern(self): + font_id = 0 + + if g.is_redraw_pattern: + for loop in g.loops: + pattern = loop.pattern + if not loop.pattern: + continue + + color = (1, 1, 1, 0.5) + verts = pattern.interior_verts + edges = pattern.interior_edges + if verts: + pass + # 减轻压力 + # draw_verts_2d(self, verts, color, 5) + + line_co_pairs = [] + for edge in edges: + line_co_pair = ( + g.obj.matrix_world @ edge.verts[0].co, + g.obj.matrix_world @ edge.verts[1].co, + ) + line_co_pairs.append(line_co_pair) + + if line_co_pairs: + draw_lines_3d(self, line_co_pairs, color, line_width=2) + + +def draw_lines_3d(self, line_co_pairs, color, line_width=2): + if not line_co_pairs: + return + + line_co_pair_flatten = list(itertools.chain(*line_co_pairs)) + + shader = self.shader_3d_uniform_color + gpu.state.blend_set("ALPHA") + self.set_line_width(line_width) + batch = batch_for_shader(shader, "LINES", {"pos": line_co_pair_flatten}) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_points_3d(self, vert_coords, color, point_size=3): + if not vert_coords: + return + + if bpy.app.version < (4, 5, 0): + shader = self.shader_3d_uniform_color + gpu.state.blend_set("ALPHA") + self.set_point_size(point_size) + batch = batch_for_shader(shader, "POINTS", {"pos": vert_coords}) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + else: + shader = self.shader_point_uniform_color + batch = batch_for_shader(shader, "POINTS", {"pos": vert_coords}) + shader.uniform_float("color", color) + gpu.state.point_size_set(point_size) + batch.draw(shader) + + +def draw_edges_2d(self, edges, color): + if not edges: + return + + edges_co = [ + tuple(g.obj.matrix_world @ vert.co for vert in edge.verts) for edge in edges + ] + context = bpy.context + region = context.region + rv3d = context.region_data + + coords_3d = edges_co + coords_2d = [ + functions.coords_3d_to_2d(region, rv3d, sublist) for sublist in coords_3d + ] + if None in coords_2d: + return + + coords_2d = list(itertools.chain.from_iterable(coords_2d)) + + shader = self.shader_2d_uniform_color + batch = batch_for_shader(shader, "LINES", {"pos": coords_2d}) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_path_2d(self, path, color, line_width=1): + if not path: + return + + edges_co = [(path[i], path[i + 1]) for i in range(len(path) - 1)] + + context = bpy.context + region = context.region + rv3d = context.region_data + + coords_3d = edges_co + coords_2d = [ + [ + (view3d_utils.location_3d_to_region_2d(region, rv3d, item) or (None, None)) + for item in sublist + if view3d_utils.location_3d_to_region_2d(region, rv3d, item) is not None + ] + for sublist in coords_3d + ] + if None in coords_2d: + return + + coords_2d = list(itertools.chain.from_iterable(coords_2d)) + + self.set_line_width(line_width) + shader = self.shader_2d_uniform_color + batch = batch_for_shader(shader, "LINES", {"pos": coords_2d}) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_patch_boundary_text(self, coord_2d, suggest_text, constraint_text): + + font_id = 0 + font_size = self.get_font_size() + + # text + if coord_2d is None: + return + x, y = coord_2d + + self.set_font_size(font_size * (2 / 3)) + offset = font_size + if suggest_text is not None: + blf.position(font_id, x, y - offset, 0) + blf.color(font_id, 0, 1, 0, 1) + blf.draw(font_id, str(suggest_text)) + offset += font_size + + if constraint_text is not None: + blf.position(font_id, x, y, 0) + blf.color(font_id, 1, 0, 0, 1) + blf.draw(font_id, str(constraint_text)) + + self.set_font_size(font_size) + + +class Draw: + def __init__(self): + self.title_handler = None + self.temp_handler = None + self.snap_vertex_handler = None + + self._draw_2d_handler = None + self._draw_3d_handler = None + + def get_font_size(self): + region = bpy.context.region + width, height = region.width, region.height + font_size = max(10, min(width, height) // 50) + return font_size + + def draw_2d(self, context): + if repr(self.op_instance).endswith("invalid>"): + self.clear() + return + + if g.is_changing_mesh: + return + + if not g.is_running: + self.clear() + return + + try: + start_time = time.time() + # draw_hovering_boundary_2d(self) + draw_hovering_boundary_2d_time = time.time() - start_time + + draw_desc(self, context) + + start_time = time.time() + draw_snap_vertex(self, context) + draw_snap_vertex_time = time.time() - start_time + + start_time = time.time() + # draw_boundaries(self) + draw_boundaries_time = time.time() - start_time + + draw_boundaries_text_2d(self) + + start_time = time.time() + # draw_patch_pattern(self) + draw_patch_pattern_time = time.time() - start_time + + # last_echo_time = g.last_echo_time if g.last_echo_time else 0 + # if time.time() - last_echo_time > 0.1: + # print( + # f"{draw_hovering_boundary_2d_time:.3f}", + # f"{draw_snap_vertex_time:.3f}", + # f"{draw_boundaries_time:.3f}", + # f"{draw_patch_pattern_time:.3f}", + # ) + # g.last_echo_time = time.time() + except Exception as e: + self.clear() + self.process_exception(e) + + # draw_loop(self) # debug + + def draw_3d(self, context): + if repr(self.op_instance).endswith("invalid>"): + self.clear() + return + + if g.is_changing_mesh: + return + + if not g.is_running: + self.clear() + return + + try: + draw_hovering_boundary(self) + + draw_hovering_loop_3d(self) + draw_temp_patch_boundary(self) + + draw_patch_pattern(self) + draw_boundaries_3d(self) + + except Exception as e: + self.clear() + self.process_exception(e) + # raise e + + def process_exception(self, e): + error_info = traceback.format_exc() + functions.log(error_info) + + # tab退出时,bm删除,这里执行循环很快,还来不及清空,会抛出ReferenceError,这个情况不向ui抛出错误 + if isinstance(e, ReferenceError): + error_info = traceback.format_exc() + + pass + else: + g.running_exception = e + # raise e + pass + + def set_line_width(self, line_width): + if bpy.app.version[0] == 3: + # 3.x + bgl.glLineWidth(line_width) + else: + # 4.x 5.x + gpu.state.line_width_set(line_width) + + def set_point_size(self, point_size): + if bpy.app.version[0] == 3: + # 3.x + bgl.glPointSize(point_size) + else: + # 4.x 5.x + gpu.state.point_size_set(point_size) + + def set_font_size(self, font_size): + font_id = 0 + if bpy.app.version[0] == 3: + # # 3.x got 3 + blf.size(font_id, font_size, 72) + else: + # 4.0 got 2 + blf.size(font_id, font_size) + + def start(self, op_instance): + functions.log("start draw") + + self.op_instance = op_instance + + if bpy.app.version[0] == 3: + if bpy.app.version[0] >= 6: + # 3.6 + self.shader_3d_uniform_color = gpu.shader.from_builtin("UNIFORM_COLOR") + self.shader_2d_uniform_color = gpu.shader.from_builtin( + "2D_UNIFORM_COLOR" + ) + else: + # 3.3 + self.shader_3d_uniform_color = gpu.shader.from_builtin( + "3D_UNIFORM_COLOR" + ) + self.shader_2d_uniform_color = gpu.shader.from_builtin( + "2D_UNIFORM_COLOR" + ) + + else: + # 4.0 5.x + self.shader_3d_uniform_color = gpu.shader.from_builtin("UNIFORM_COLOR") + self.shader_2d_uniform_color = gpu.shader.from_builtin("UNIFORM_COLOR") + + if bpy.app.version >= (4, 5, 0): + self.shader_point_uniform_color = gpu.shader.from_builtin("POINT_UNIFORM_COLOR") + + if not self._draw_2d_handler: + self._draw_2d_handler = bpy.types.SpaceView3D.draw_handler_add( + self.draw_2d, + (bpy.context,), + "WINDOW", + "POST_PIXEL", + ) + if not self._draw_3d_handler: + self._draw_3d_handler = bpy.types.SpaceView3D.draw_handler_add( + self.draw_3d, + (bpy.context,), + "WINDOW", + "POST_VIEW", + ) + + def clear(self): + functions.log("darw cleared") + if self._draw_2d_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_2d_handler, "WINDOW") + self._draw_2d_handler = None + + if self._draw_3d_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_3d_handler, "WINDOW") + self._draw_3d_handler = None + + +draw = Draw() diff --git a/g.py b/g.py new file mode 100644 index 0000000..8598c3d --- /dev/null +++ b/g.py @@ -0,0 +1,118 @@ +from enum import Enum + + +def clear_all_variables(): + global_vars = globals() + + for var in list(global_vars): + if not var.startswith("__") and not callable(global_vars[var]): + global_vars[var] = None + + init_variables() + + +def init_variables(): + global_vars = globals() + + global boundaries + boundaries = set() + + global patches + patches = set() + + global loops + loops = [] + + global temp_bm_verts + temp_bm_verts = set() + + global temp_bm_edges + temp_bm_edges = set() + + global snap_bvh_trees + snap_bvh_trees = [] + + global last_update_hovering_timestamp + last_update_hovering_timestamp = 0 + + global last_update_snap_timestamp + last_update_snap_timestamp = 0 + + global mode + mode = Mode.NORMAL + + global is_snap_surface + is_snap_surface = True + + global is_auto_search_pattern + is_auto_search_pattern = True + + global is_snap_opposite + is_snap_opposite = True + + +is_running = False +running_exception = None # 存放外部发生的错误,让他可以raise到用户界面 + +# 绘制曲线中 +drawing_verts_snap_vertex = None +drawing_path_boundary = None + + +patches = set() # 建成的补丁 +boundaries = set() # 建成的边 +loops = ( + [] +) # boundary环路列表,元素是loop,loop是元组的列表,元组第一个元素是boundary,第二个元素是方向 + +# 记录插件创建的点和边,防止误删搞坏用户的网格,使用set,提高in语句的性能 +# TODO: 網格更新的時候沒有及時更新,但是暂时没问题,不考虑这个,只在最后获取顶点数的时候有问题 +temp_bm_verts = set() +temp_bm_edges = set() + +# 全局设置 +is_snap_surface = True +is_snap_opposite = True +is_auto_search_pattern = True + +snap_bvh_trees = [] # 要吸附的bvh_tree +obj = None # 编辑物体 +obj_bm = None # 编辑物体的bm +obj_me = None # 编辑物体的data即mesh对象 +obj_bvh_tree = None # 编辑物体的data即mesh对象 + +hovering_loop = None +hovering_boundary = None + +temp_merge_boundary_1 = None # 临时变量,用于临时储存合并boundary + +last_update_hovering_timestamp = 0 +last_update_snap_timestamp = 0 + +last_mouse_co_2d = None + +last_echo_time = 0 + + +class Mode(Enum): + NORMAL = 1 + DRAWING = 2 + MERGE_BOUNDARY = 3 + + +mode = Mode.NORMAL + +mirror_path = [] + +is_drawing_along_mirror = False +is_drawing_along_mirror_persist = False + +is_changing_mesh = False + + +is_redraw_boundary = False +last_boundary_data = None +is_redraw_pattern = False +last_pattern_data = None +is_redraw_hovering = False +last_hovering_data = None diff --git a/generate_loop.py b/generate_loop.py new file mode 100644 index 0000000..6c59b4d --- /dev/null +++ b/generate_loop.py @@ -0,0 +1,194 @@ +from . import g +from .loop import Loop +from collections import defaultdict + +# 因为要引用Loop patch,所以单独放了 + + +def regenerate_loops(is_force_refresh=False): + boundaries = g.boundaries # 假设 'g' 有一个 'boundaries' 属性,它包含边界对象 + graph = {} + + # 构建无向图 + for boundary in boundaries: + graph.setdefault(boundary.start_vertex, []).append(boundary.end_vertex) + graph.setdefault(boundary.end_vertex, []).append(boundary.start_vertex) + + # 方向字典 + boundary_direction = { + (boundary.start_vertex, boundary.end_vertex): (boundary, True) + for boundary in boundaries + } + boundary_direction.update( + { + (boundary.end_vertex, boundary.start_vertex): (boundary, False) + for boundary in boundaries + } + ) + + def dfs(node, start, path, visited, current_loops): + max_length = 6 + if len(path) > max_length: + return + + visited.add(node) + path.append(node) + + for neighbor in graph[node]: + # 2条边在拓扑结构上是一摸一样的,没办法通过拓扑办法识别最小圈 + if neighbor == start and len(path) > 2: + # 找到一个环路,创建对应的环路(每条边是一个元组,包含boundary和方向) + loop = Loop() + for i in range(len(path) - 1): + start_vertex = path[i] + end_vertex = path[i + 1] + if (start_vertex, end_vertex) in boundary_direction: + boundary, direction = boundary_direction[ + (start_vertex, end_vertex) + ] + loop.add_edge(boundary, direction) + else: + boundary, direction = boundary_direction[ + (end_vertex, start_vertex) + ] + loop.add_edge(boundary, direction) + + # 处理闭合边(path[-1] 到 path[0]) + start_vertex = path[-1] + end_vertex = path[0] + if (start_vertex, end_vertex) in boundary_direction: + boundary, direction = boundary_direction[(start_vertex, end_vertex)] + loop.add_edge(boundary, direction) + else: + boundary, direction = boundary_direction[(end_vertex, start_vertex)] + loop.add_edge(boundary, direction) + + current_loops.append(loop) + elif neighbor not in visited: + dfs(neighbor, start, path, visited, current_loops) + + path.pop() + visited.remove(node) + + loops = [] # 所有环路 + for node in graph: + current_loops = [] # 用来存储当前节点发现的环路 + dfs(node, node, [], set(), current_loops) + loops.extend(current_loops) # 将当前节点的环路加入到结果中 + + # 去重环路 + unique_loops = set(loops) # 使用 set 去重环路 + + # 删除长度超过6的 + unique_loops = {loop for loop in unique_loops if len(loop.boundaries) <= 6} + + # 如果一个环路,他有弦,则删掉 + to_be_delete_loops = set() + + # 获取连续1 2 3条boundary的映射,并且要标记属于哪个loop的 + start_v_and_end_v_to_loops_of_length = [ + defaultdict(set) for _ in range(3) + ] # 0是长度为1的,1为长度为2的,2为长度为3的,元素为字典,key是两个顶点,value是相关的loop + for loop in unique_loops: + verts = loop.controll_verts + for vert_idx, vert in enumerate(verts): + for offset in range(1, 4): # 1~3 + vert_1 = verts[vert_idx] + vert_2 = verts[(vert_idx + offset) % len(verts)] + + sorted_start_v_and_end_v_tuple = tuple( + sorted([vert_1, vert_2], key=lambda v: v.index) + ) + start_v_and_end_v_to_loops_of_length[offset - 1][ + sorted_start_v_and_end_v_tuple + ].add(loop) + + # # debug形状 + # print(len(start_v_and_end_v_to_loops_of_length)) + # for i, start_v_and_end_v_to_loops in enumerate( + # start_v_and_end_v_to_loops_of_length + # ): + # print(f"长度{i+1}") + # for (vert_1, vert_2), loops in start_v_and_end_v_to_loops.items(): + # print(len(loops)) + + for loop in unique_loops: + if loop.length <= 3: # 小于3不检查弦 + continue + + offset_range = loop.length - 3 + + loop_controll_verts = loop.controll_verts + + for offset in range(1, offset_range + 1): + # 隔一个顶点就检查1条的,隔两个顶点就检查1和2条的,隔三个就检查123条的 + # offset就是boundary的条数 + + # 遍历两两不相邻的顶点 + for i in range(len(loop_controll_verts)): + for j in range(len(loop_controll_verts)): + # 确保 j 不等于 i,并且 j 不是 i 的相邻边界 + if ( + i != j + and (j != (i + 1) % len(loop_controll_verts)) + and (j != (i - 1) % len(loop_controll_verts)) + ): + vert_1 = loop_controll_verts[i] + vert_2 = loop_controll_verts[j] + sorted_start_v_and_end_v_tuple = tuple( + sorted([vert_1, vert_2], key=lambda v: v.index) + ) + # 隔一个顶点就检查1条的,2和3跳过 + # 如果 i 和 j 隔了 1 个顶点,跳过 offset 为 1 和 2 + distance = min( + abs(i - j), len(loop_controll_verts) - abs(i - j) + ) # 0 1距离1 + + # print(loop.length, distance, offset) + + if distance == 2 and (offset == 2 or offset == 3): + continue # 跳过 offset 为 1 或 2 的情况 + + # 如果 i 和 j 隔了 2 个顶点,跳过 offset 为 2 + if distance == 3 and offset == 2: + continue # 跳过 offset 为 2 的情况 + + related_loops = start_v_and_end_v_to_loops_of_length[ + offset - 1 + ].get(sorted_start_v_and_end_v_tuple) + if not related_loops: + continue + # 排除自身的影响,并且用-号而不用remove()这样搞不会导致start_v_and_end_v_to_loops_of_length被修改 + remaining_loops = related_loops - {loop} + if remaining_loops: + to_be_delete_loops.add(loop) + # TODO:一旦被认为是弦,则对该loop的循环就要退出了,但是层数太多了 + break + + # # 从所有的loops中删除外层loop + unique_loops.difference_update(to_be_delete_loops) + + # 防止重建网格 + updated_loops = [] + if not is_force_refresh: + loop_tobe_assign = set() + for loop in unique_loops: + if loop in g.loops: + continue + g.loops.append(loop) + updated_loops.append(loop) + else: + g.loops = unique_loops + updated_loops = unique_loops + + # print(len(g.loops)) + # for loop in g.loops: + # print("---") + # print(loop.shape) + # for b, d in loop.b_and_d_list: + # print(b.start_vertex.index, b.end_vertex.index, d) + + for updated_loop in updated_loops: + updated_loop.recreate_pattern() + + return updated_loops diff --git a/loop.py b/loop.py new file mode 100644 index 0000000..bea2f06 --- /dev/null +++ b/loop.py @@ -0,0 +1,318 @@ +import bmesh +import mathutils +import traceback + +from . import pattern +from .pattern import pattern_list +from . import g + + +class Loop: + def __init__(self): + self.b_and_d_list = [] # 存储边界和方向的元组 (boundary, direction) + + self.bvh_tree = None + self.pattern = None # create要调用到,没声明报错 + self.mid_co = mathutils.Vector((0, 0, 0)) + + self.solver_msg = "" + self.suggest_solution = [] + self.solver_constraint_list = [ + None for _ in range(8) + ] # 丑陋储存法,用None作占位符,现存的pattern最多有八个约束(6个padding 2个x y 附加边流) + self.solver_constraint_rotation = None + self.solver_constraint_pattern = None + + def add_edge(self, boundary, direction): + self.b_and_d_list.append((boundary, direction)) + self.update_mid_co() + + def update_mid_co(self): + self.mid_co = sum( + [b.mid_co for b in self.boundaries], mathutils.Vector() + ) / len(self.boundaries) + + def normalize(self): + """标准化环路,将每条边的顶点索引进行排序,忽略边的方向""" + return sorted( + [ + (id(boundary), boundary) # Sort by the unique id of the boundary object + for boundary, _ in self.b_and_d_list + ] + ) + + def __eq__(self, other): + """重载 '==' 运算符,用来进行环路set的去重""" + return self.normalize() == other.normalize() + + def __hash__(self): + """重载 '__hash__' 使其能够作为字典的键进行去重""" + return hash(tuple(self.normalize())) + + def __repr__(self): + return f"Loop({self.b_and_d_list})" + + @property + def length(self): + return len(self.b_and_d_list) + + @property + def shape(self): + # 这个是指边数 + return [len(b.verts) - 1 for b, _ in self.b_and_d_list] + + @property + def boundaries(self): + return [b for b, _ in self.b_and_d_list] + + @property + def boundary_verts_walk(self): + # 有重复,用于createmesh的时候walk,即每个boundary的头尾都是重叠的 + verts = [] + for b, d in self.b_and_d_list: + if d: + boundary_verts = b.verts + else: + boundary_verts = b.verts[::-1] + for v in boundary_verts: + verts.append(v) + + return verts + + @property + def verts(self): + # 无重复顶点 + verts = [] + seen = set() # 用来跟踪已访问的顶点 + + for b, d in self.b_and_d_list: + if d: + boundary_verts = b.verts # 如果方向为真,按顺序获取顶点 + else: + boundary_verts = b.verts[::-1] # 如果方向为假,反转顶点顺序 + + # 遍历当前边界的顶点 + for v in boundary_verts: + if v not in seen: # 如果顶点未被访问过 + verts.append(v) + seen.add(v) # 标记该顶点为已访问 + + return verts + + @property + def controll_verts(self): + # 即start和end的列表,无重复顶点 + controll_verts = [] + + for idx, (b, d) in enumerate(self.b_and_d_list): + if d: + vert_1 = b.start_vertex + vert_2 = b.end_vertex + else: + vert_1 = b.end_vertex + vert_2 = b.start_vertex + + controll_verts.append(vert_1) + + return controll_verts + + @property + def edges(self): + edges = [] + for b, d in self.b_and_d_list: + if d: + boundary_edges = b.edges + else: + boundary_edges = b.edges[::-1] + + for e in boundary_edges: + edges.append(e) + + return edges + + # @property + # def controll_verts(self): + # # 控制点,即每个boundary的start end + # return set(v for b in self.boundaries for v in b.verts) + + @property + def current_solution(self): + if self.pattern and self.pattern.solution: + return self.pattern.solution + + return None + + def create_bvh_tree(self): + temp_bm = bmesh.new() + + temp_bm_verts = [] + for b in self.boundaries: + for vert in b.verts: + vert = temp_bm.verts.new(vert.co) + temp_bm_verts.append(vert) + + # 确保新创建的顶点的索引是正确的 + temp_bm.verts.index_update() + temp_bm.verts.ensure_lookup_table() + + temp_bm.faces.new(temp_bm_verts) # ngon + + bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(temp_bm) + self.bvh_tree = bvh_tree + + temp_bm.free() + + def recreate_pattern(self): + # 尝试创建pattern + + self.remove_pattern() + try: + pattern_instance = pattern.recreate_pattern(self) + except Exception as e: + pattern_instance = None + error_info = traceback.format_exc() + g.running_exception = e + + bmesh.update_edit_mesh(g.obj_me) + + self.pattern = pattern_instance + + g.is_redraw_pattern = True + return self.pattern + + def remove_pattern(self): + if self.pattern: + self.pattern.remove_mesh() + self.pattern = None + + def get_pattern_constraint(self): + # 获取当前pattern,如果有约束则返回约束,无约束则返回当前pattern实例,如无解则返回None + if self.solver_constraint_pattern: + return self.solver_constraint_pattern + elif self.pattern: + # 不应返回pattern实例,后面还要copy,有些属性无法复制 + for i in pattern.pattern_list: + if i.name == self.pattern.name: + return i + return None + + def change_solver_constraint_rotation(self): + current_pattern = self.get_pattern_constraint() + if not current_pattern: + return + + rotation = self.solver_constraint_rotation + if rotation is None: + if self.pattern: + new_rotation = self.pattern.rotation + 1 + else: + new_rotation = 0 + else: + new_rotation = rotation + 1 + new_rotation = new_rotation % len(self.boundaries) + + self.solver_constraint_rotation = new_rotation + self.solver_constraint_pattern = current_pattern + self.recreate_pattern() + + def next_solver_constraint_pattern(self, is_auto_search=True): + current_pattern = self.get_pattern_constraint() + + # 初始化约束,设置pattern约束 + boundary_num = len(self.boundaries) + pattern_list_current = [ + i for i in pattern_list if boundary_num == i.boundary_num + ] + self.init_solver_constraint() + if current_pattern is None: + loop_start_idx = 0 + else: + loop_start_idx = next( + i + for i, obj in enumerate(pattern_list_current) + if obj.name == current_pattern.name + ) + + for pattern in ( + pattern_list_current[loop_start_idx + 1 :] + + pattern_list_current[:loop_start_idx] + ): # 不跳过第一个,会导致无法切换到下一个 + self.solver_constraint_pattern = pattern + result = self.recreate_pattern() + if not is_auto_search: + # 不是自动模式,则拿到第一个就溜 + return + + if result is not None: + return + # 找不到,回到原处 + self.solver_constraint_pattern = current_pattern + self.recreate_pattern() + + def init_solver_constraint( + self, is_init_list=True, is_init_rotation=True, is_init_pattern=True + ): + # 另外两个约束不能单独保留 + if is_init_pattern: + is_init_list = True + is_init_rotation = True + + if is_init_list: + self.solver_constraint_list = [None for _ in range(len(self.shape) + 2)] + if is_init_rotation: + self.solver_constraint_rotation = None + + if is_init_pattern: + self.solver_constraint_pattern = None + + self.recreate_pattern() + + def change_solver_constraint_list(self, key, change): + current_pattern = self.get_pattern_constraint() + + if not current_pattern: + return + + if not self.solver_constraint_pattern and not self.pattern: + return + + if isinstance(key, int): + solution_idx = key + else: + edge_var_count = len(current_pattern.additional_edge_flow_info) + if key == "x" and edge_var_count >= 1: + solution_idx = len(self.shape) - 1 + 1 + elif key == "y" and edge_var_count >= 2: + solution_idx = len(self.shape) - 1 + 2 + elif key == "z" and edge_var_count >= 3: + solution_idx = len(self.shape) - 1 + 3 + else: + return + + # 是否有约束,没有再找solution + solver_constraint_list = self.solver_constraint_list + old_value = solver_constraint_list[solution_idx] + + if old_value is None: + current_solution = self.current_solution + if current_solution: + old_value = current_solution[solution_idx] + if old_value is None: + old_value = 0 + + new_value = old_value + change + if new_value < 0: + return + + upper = max(self.shape) + if new_value > upper: + return + + self.solver_constraint_list[solution_idx] = new_value + self.solver_constraint_pattern = current_pattern + self.recreate_pattern() + + def get_boundary_index(self, boundary_input): + for idx, b in enumerate(self.boundaries): + if b == boundary_input: + return idx diff --git a/op.py b/op.py new file mode 100644 index 0000000..7113722 --- /dev/null +++ b/op.py @@ -0,0 +1,708 @@ +from __future__ import annotations +import bpy +import bmesh +from bpy_extras import view3d_utils +import time +import mathutils + + +from . import const, g +from .utils import functions +from .draw import draw +from . import boundary +from . import generate_loop +from .utils.trans import t + + +class EasyPatchModalOperator(bpy.types.Operator): + bl_idname = f"{const.addon_prefix}.start" + bl_label = "Start Patching" + bl_options = {"REGISTER", "UNDO"} # 不加UNDO撤回的话,会导致用户前一个步骤也撤回 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def modal(self, context, event): + try: + if context.area: + context.area.tag_redraw() + + result = self.process_undo(context, event) + if result is not None: + return result + + # start_timestamp = time.time() + # process drag + result = self.process_drawing_verts(context, event) + if result is not None: + return result + # drawing_verts_consume_time = time.time() - start_timestamp + + # process stop + result = self.process_stop(context, event) + if result is not None: + return result + + # start_timestamp = time.time() + result = self.process_running(context, event) + if result is not None: + return result + # running_consume_time = time.time() - start_timestamp + + # start_timestamp = time.time() + result = self.process_loop(context, event) + if result is not None: + return result + # process_loop_consume_time = time.time() - start_timestamp + + # start_timestamp = time.time() + update_hover(context, event) + # update_hover_consume_time = time.time() - start_timestamp + + # last_echo_time = g.last_echo_time if g.last_echo_time else 0 + # if time.time() - last_echo_time > 0.1: + # print( + # drawing_verts_consume_time, + # running_consume_time, + # process_loop_consume_time, + # update_hover_consume_time, + # ) + # g.last_echo_time = time.time() + + # + g.last_mouse_co_2d = mathutils.Vector( + (event.mouse_region_x, event.mouse_region_y) + ) + return self.process_ui_block(context, event) + + except Exception as e: + functions.safe_execute(self.clear, context) + functions.log(f"An expection raise when modal, exit modal. Error: {str(e)}") + self.report( + {"WARNING"}, + f"An expection raise when modal, exit modal. Error: {str(e)}", + ) + EasyPatchModalOperator.set_stop_flag() + raise e + + return {"PASS_THROUGH"} + # return {"RUNNING_MODAL"} + + def invoke(self, context, event): + if not g.is_running: + self.start(context) + return {"RUNNING_MODAL"} + else: + EasyPatchModalOperator.stop() + return {"FINISHED"} + + def start(self, context): + functions.log("modal start") + + g.clear_all_variables() + + draw.start(self) + g.is_running = True + self._timer = context.window_manager.event_timer_add(0.1, window=context.window) + context.window_manager.modal_handler_add(self) + + self.context = context + self.region = context.region + self.rv3d = context.region_data + + obj = functions.get_edit_obj() + g.obj = obj + g.obj_me = obj.data + g.obj_bm = bmesh.from_edit_mesh(obj.data) + + g.mode = g.Mode.NORMAL + g.drawing_verts_snap_vertex = None + g.drawing_path_boundary = None + + g.snap_bvh_trees = functions.get_snap_bvh_trees() + g.obj_bvh_tree = functions.get_obj_bvh_tree() + + def stop(): + functions.log("modal stop") + EasyPatchModalOperator.set_stop_flag() + + def clear(self, context, remove_mesh_flag=True): + functions.log("modal clear") + + # 用户tab离开编辑模式,会导致bm被删除,所以会报很多错 + + if remove_mesh_flag: + functions.safe_execute(self.remove_mesh) + + functions.safe_execute(context.window_manager.event_timer_remove, self._timer) + functions.safe_execute(EasyPatchModalOperator.set_stop_flag) + functions.safe_execute(draw.clear) + + if context.object.mode == "EDIT": + bmesh.update_edit_mesh(g.obj_me) + + g.clear_all_variables() + + def set_stop_flag(): + g.is_running = False + + # 删除插件生成的网格 + def remove_mesh(self): + if g.temp_bm_edges: + functions.remove_bm_edges(g.obj_bm, g.temp_bm_edges) + g.temp_bm_edges = set() + + if g.temp_bm_verts: + functions.delete_bm_verts(g.obj_bm, g.temp_bm_verts) + g.temp_bm_verts = set() + + for loop in g.loops: + if loop.pattern: + loop.pattern.remove_mesh() + + try: + bmesh.update_edit_mesh(g.obj_me) + except Exception as e: + pass + + def process_stop(self, context, event): + if ( + not g.is_running + or event.type == "ESC" + or context.object.mode != "EDIT" + or g.obj_bm is None + ): + self.clear(context) + return {"FINISHED"} + + if event.type in ["RET", "NUMPAD_ENTER"]: + count_vertex = functions.get_created_verts_num() + + self.clear(context, remove_mesh_flag=False) + self.report({"INFO"}, f"{count_vertex} {t('verts')} {t('created')}") + return {"FINISHED"} + + if g.running_exception: + raise g.running_exception + + def process_drawing_verts(self, context, event): + # TODO: update snap和hovering和append_from_2d加一个移动阈值才更新,记录上一个鼠标位置,超过阈值才进行,modal末尾更新,共用一个值 + # TODO: loop加上一个屏蔽pattern生成的状态 + functions.update_snap_vertex(event, is_snap_opposite=g.is_snap_opposite) + + if g.mode == g.Mode.DRAWING: + # # update and clear snap vertex when ctrl in running + functions.update_solid_path(context, event) + + # append路径 + g.drawing_path_boundary.append_path_from_2d( + context, + (event.mouse_region_x, event.mouse_region_y), + is_snap_surface=g.is_snap_surface, + is_snap_opposite=g.is_snap_opposite, + ) + + # drawing中改变点数 + if event.type == "WHEELUPMOUSE": + g.drawing_path_boundary.length += 1 + return {"RUNNING_MODAL"} + if event.type == "WHEELDOWNMOUSE": + g.drawing_path_boundary.length -= 1 + return {"RUNNING_MODAL"} + + # stop drag + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + # # 第六条强制闭合 + # if len(self.patch.boundaries) == 5: + # g.drawing_verts_snap_vertex = self.patch.start_vertex + + snap_vert = ( + g.drawing_verts_snap_vertex + ) # 确保一致性,先获取,防止中间修改 + functions.log("stop drag vertex:" + str(snap_vert)) + + done_boundary = g.drawing_path_boundary.done_from_temp(snap_vert) + + g.drawing_verts_snap_vertex = None + g.mode = g.Mode.NORMAL + + # clear drag things + g.drawing_path_boundary = None + + if not done_boundary: + return + + if done_boundary.path[-1] != done_boundary.end_vertex.co: + # 不知道为什么,end_vertex是吸附的时候需要手动补 + done_boundary.path.append( + g.obj.matrix_world @ done_boundary.end_vertex.co + ) + + generate_loop.regenerate_loops() + + if not g.is_drawing_along_mirror_persist: + g.is_drawing_along_mirror = False # 每次画完都重置这个变量 + + # 删除temp boundary + if event.type == "RIGHTMOUSE" and event.value == "PRESS": + g.drawing_path_boundary = None + g.mode = g.Mode.NORMAL + + return {"RUNNING_MODAL"} + + # if event.value != "NOTHING": + # print(event.type, event.value) + + # TODO: 以CLICK_DRAG,用户可能会以左键单击误触发,造成多余的线条,多一个ctrl+enter,以保留多余线条 + # start drag + if event.type == "LEFTMOUSE" and event.value == "CLICK_DRAG": + functions.log("start drag vertex:" + str(g.drawing_verts_snap_vertex)) + g.mode = g.Mode.DRAWING + + # create temp boundary + if g.drawing_verts_snap_vertex: + g.drawing_path_boundary = boundary.Boundary.create_temp( + g.drawing_verts_snap_vertex + ) + else: + g.drawing_path_boundary = boundary.Boundary.create_temp(None) + + return { + "RUNNING_MODAL" + } # block drag ui operation, if not running_modal, release will not trigger + + def process_undo(self, context, event): + if event.type == "Z" and event.value == "PRESS" and event.ctrl: + + self.report({"WARNING"}, t("you_cant_undo_here")) + return {"RUNNING_MODAL"} + + def process_ui_block(self, context, event): + # I键内插,会导致未知问题 + if event.type in ["X", "I"]: + return {"RUNNING_MODAL"} + + return {"PASS_THROUGH"} + + def process_loop(self, context, event): + if not g.hovering_loop: + return + + loop = g.hovering_loop + + # R + if event.type == "R" and event.value == "PRESS": + loop.change_solver_constraint_rotation() + return {"RUNNING_MODAL"} + + # T + if event.type == "T" and event.value == "PRESS": + loop.next_solver_constraint_pattern(is_auto_search=g.is_auto_search_pattern) + return {"RUNNING_MODAL"} + + # invert normal + if event.type == "I" and event.value == "PRESS" and loop.pattern: + functions.faces_normal_toward_viewport(g.obj, g.obj_bm, loop.pattern.faces) + return {"RUNNING_MODAL"} + + # 改变padding约束 + if ( + event.shift + and (event.type == "WHEELUPMOUSE" or event.type == "WHEELDOWNMOUSE") + and g.hovering_boundary + ): + boundary_index = loop.get_boundary_index(g.hovering_boundary) + if event.type == "WHEELUPMOUSE": + loop.change_solver_constraint_list(boundary_index, 1) + if event.type == "WHEELDOWNMOUSE": + loop.change_solver_constraint_list(boundary_index, -1) + + # X附加边流 + if event.type == "X" and event.value == "PRESS" and event.shift: + loop.change_solver_constraint_list("x", 1) + return {"RUNNING_MODAL"} + if event.type == "X" and event.value == "PRESS" and event.ctrl: + loop.change_solver_constraint_list("x", -1) + return {"RUNNING_MODAL"} + + # Y附加边流 + if event.type == "Y" and event.value == "PRESS" and event.shift: + loop.change_solver_constraint_list("y", 1) + return {"RUNNING_MODAL"} + if event.type == "Y" and event.value == "PRESS" and event.ctrl: + loop.change_solver_constraint_list("y", -1) + return {"RUNNING_MODAL"} + + # C 清除约束 + if event.type == "C" and event.value == "PRESS": + if event.shift: + loop.init_solver_constraint( + is_init_list=True, is_init_rotation=False, is_init_pattern=False + ) + elif event.ctrl: + loop.init_solver_constraint( + is_init_list=False, is_init_rotation=True, is_init_pattern=False + ) + else: + loop.init_solver_constraint() + return {"RUNNING_MODAL"} + + def process_running(self, context, event): + if g.mode != g.Mode.NORMAL: + return + # 边缘建成后,改变长度 + if event.ctrl and ( + event.type == "WHEELUPMOUSE" or event.type == "WHEELDOWNMOUSE" + ): + # boundary = functions.get_nearest_boundary_from_mouse(event) + boundary = g.hovering_boundary + if boundary: + if event.type == "WHEELUPMOUSE": + boundary.length += 1 + if event.type == "WHEELDOWNMOUSE": + boundary.length -= 1 + return {"RUNNING_MODAL"} + + # S + if event.type == "S" and event.value == "PRESS": + g.is_snap_surface = not g.is_snap_surface # 切换吸附状态 + + if g.hovering_loop and g.hovering_loop.pattern: + if g.is_snap_surface: + # 吸附曲面 + functions.snap_verts_to_surface_along_normal( + g.obj, + g.obj_bm, + g.hovering_loop.pattern.interior_verts, + g.snap_bvh_trees, + allow_opposite_direction=g.is_snap_opposite, + ) + else: + functions.smooth_verts_by_normal( + g.obj, g.obj_bm, g.hovering_loop.pattern.interior_verts + ) + + return {"RUNNING_MODAL"} + + if event.type == "M" and event.value == "PRESS" and not event.ctrl: + g.is_drawing_along_mirror = ( + not g.is_drawing_along_mirror + ) # 切换镜像绘制状态 + + if not g.is_drawing_along_mirror: + g.is_drawing_along_mirror_persist = False + return {"RUNNING_MODAL"} + + # 删除boundary + if ( + event.type == "X" + and event.value == "PRESS" + and not event.shift + and not event.ctrl + and g.hovering_boundary + ): + g.hovering_boundary.delete() + generate_loop.regenerate_loops() + return {"RUNNING_MODAL"} + + if event.type == "M" and event.value == "PRESS" and event.ctrl: + g.is_drawing_along_mirror_persist = ( + not g.is_drawing_along_mirror_persist + ) # 切换镜像绘制状态 + + g.is_drawing_along_mirror = g.is_drawing_along_mirror_persist + return {"RUNNING_MODAL"} + + # smooth + if event.shift: + tobe_smooth_loops = [] + if event.ctrl: + tobe_smooth_loops = g.loops + else: + if g.hovering_loop: + tobe_smooth_loops = [g.hovering_loop] + + for tobe_smooth_loop in tobe_smooth_loops: + if ( + not tobe_smooth_loop.pattern + or not tobe_smooth_loop.pattern.interior_verts + ): + continue + to_be_smooth_verts = tobe_smooth_loop.pattern.interior_verts + + if event.type in ["ONE", "NUMPAD_1"]: + functions.smooth_verts_by_avg(g.obj, g.obj_bm, to_be_smooth_verts) + return {"RUNNING_MODAL"} + elif event.type in ["TWO", "NUMPAD_2"]: + functions.smooth_verts_by_normal( + g.obj, + g.obj_bm, + to_be_smooth_verts, + smooth_type=0, + ) + return {"RUNNING_MODAL"} + elif event.type in ["THREE", "NUMPAD_3"]: + functions.smooth_verts_by_normal( + g.obj, + g.obj_bm, + to_be_smooth_verts, + smooth_type=1, + ) + return {"RUNNING_MODAL"} + # self.is_snap_surface = False + + # v snap oppose,目前只针对path绘制的时候应用 + if event.type == "V" and event.value == "PRESS" and g.is_running: + if g.is_snap_opposite: + g.is_snap_opposite = False + else: + g.is_snap_opposite = True + return {"RUNNING_MODAL"} + + if event.type == "B" and event.value == "PRESS": + g.is_auto_search_pattern = not g.is_auto_search_pattern + return {"RUNNING_MODAL"} + + +class EasyPatchSnapSelectedToSurfaceOperator(bpy.types.Operator): + bl_idname = f"{const.addon_prefix}.snap_selected_to_surface" + bl_label = "Snap Selected Vertices to Surface" + # bl_description = t("toggle_driver") + bl_options = {"REGISTER", "UNDO"} # 不加UNDO撤回的话,会导致用户前一个步骤也撤回 + + def execute(self, context): + obj = context.object + + # 检查是否在编辑模式 + if obj.mode != "EDIT": + self.report({"WARNING"}, "Please run this in edit mode") + return {"FINISHED"} + + # 获取依赖图 + depsgraph = context.evaluated_depsgraph_get() + + # 获取网格数据 + bm = bmesh.from_edit_mesh(obj.data) + bm.verts.ensure_lookup_table() + + # 获取所有选中的顶点 + selected_vertices = [v for v in bm.verts if v.select] + if not selected_vertices: + self.report({"WARNING"}, "No Vertices selected") + return {"FINISHED"} + + # 获取所有可见的网格对象(排除当前对象) + visible_objects = [ + o + for o in context.visible_objects + if o.type == "MESH" and o != obj and o.visible_get() + ] + + if not visible_objects: + self.report({"WARNING"}, "no_visible_target_mesh_to_snap") + return {"FINISHED"} + + # 构建所有可见目标对象的 BVH 树(一次性操作) + bvh_trees = functions.get_snap_bvh_trees() + + functions.snap_verts_to_surface_along_normal( + obj, + bm, + selected_vertices, + bvh_trees, + allow_opposite_direction=False, + ) + + # 将顶点更改应用到网格上,loop_triangles提高效率 + bmesh.update_edit_mesh(obj.data, loop_triangles=True) + + # self.report({"INFO"}, f"Successfully snap {snap_count} vertices") + return {"FINISHED"} + + +def get_nearest_boundary_and_loop_by_co_2d(context, co_2d): + nearest_boundary = None + nearest_loop = None + min_distance = float("inf") + + # 获取当前视图区域和区域的3D视图 + region = context.region + rv3d = context.space_data.region_3d + + # # mid_co方案 + # # 遍历boundary列表 + # for boundary in g.boundaries: + # # 获取boundary的mid_co三维坐标 + # mid_co = boundary.mid_co + + # # 将三维坐标投影到2D屏幕坐标 + # screen_coord = view3d_utils.location_3d_to_region_2d(region, rv3d, mid_co) + + # # 如果投影成功 + # if screen_coord: + # # 计算鼠标位置与投影点的距离 + # dist = (screen_coord[0] - co_2d[0]) ** 2 + (screen_coord[1] - co_2d[1]) ** 2 + # if dist < min_distance: + # min_distance = dist + # nearest_boundary = boundary + + # TODO: 只有一个edge的用mid_co,不然重叠了捕获不到 + # 遍历boundary列表 + for boundary in g.boundaries: + coords = [v.co for v in boundary.verts] + [boundary.mid_co] + # 遍历boundary的每一个顶点 + for vert_co in coords: + # 将三维坐标投影到2D屏幕坐标 + screen_coord = view3d_utils.location_3d_to_region_2d(region, rv3d, vert_co) + + # 如果投影成功 + if screen_coord: + # 计算鼠标位置与投影点的距离 + dist = (screen_coord[0] - co_2d[0]) ** 2 + ( + screen_coord[1] - co_2d[1] + ) ** 2 + if dist < min_distance: + min_distance = dist + nearest_boundary = boundary + + if nearest_boundary: + # 获取与nearest_boundary相关的loop + connected_loops = functions.get_loops_from_boundary(nearest_boundary) + # print(nearest_boundary, connected_loops) + + if len(connected_loops) == 0: + nearest_loop = None + elif len(connected_loops) == 1: + nearest_loop = connected_loops[0] + else: + # 找到距离鼠标位置最近的loop + min_loop_distance = float("inf") + + for loop in connected_loops: + # 获取loop的mid_co三维坐标 + loop_mid_co = loop.mid_co + + # 将loop的三维坐标投影到2D屏幕坐标 + loop_screen_coord = view3d_utils.location_3d_to_region_2d( + region, rv3d, loop_mid_co + ) + + # 如果loop投影成功 + if loop_screen_coord: + # 计算鼠标位置与loop的投影点的距离 + loop_dist = (loop_screen_coord[0] - co_2d[0]) ** 2 + ( + loop_screen_coord[1] - co_2d[1] + ) ** 2 + if loop_dist < min_loop_distance: + min_loop_distance = loop_dist + nearest_loop = loop + + return nearest_boundary, nearest_loop + + +def get_nearest_boundary_and_loop_by_co_3d(co_3d): + nearest_boundary = None + nearest_loop = None + min_distance = float("inf") + + # # mid_co方案 + # # 遍历boundary列表 + # for boundary in g.boundaries: + # mid_co = boundary.mid_co + + # # 计算给定的3D坐标与boundary的mid_co之间的欧几里得距离 + # distance = (mid_co - co_3d).length + + # # 判断是否是最近的boundary + # if distance < min_distance: + # min_distance = distance + # nearest_boundary = boundary + + # verts方案 + for boundary in g.boundaries: + coords = [v.co for v in boundary.verts] + [boundary.mid_co] + # 遍历boundary的每一个顶点 + for vert_co in coords: + + # 计算给定的3D坐标与顶点坐标之间的欧几里得距离 + distance = (vert_co - co_3d).length + + # 判断是否是最近的boundary + if distance < min_distance: + min_distance = distance + nearest_boundary = boundary + + if nearest_boundary: + connected_loops = functions.get_loops_from_boundary(nearest_boundary) + if len(connected_loops) == 0: + nearest_loop = None + elif len(connected_loops) == 1: + nearest_loop = connected_loops[0] + else: + # 多个loop的情况,计算每个loop的mid_co和目标co_3d之间的3D距离 + min_loop_distance = float("inf") + + for loop in connected_loops: + loop_mid_co = loop.mid_co + + # 计算给定的3D坐标与loop的mid_co之间的欧几里得距离 + loop_distance = (loop_mid_co - co_3d).length + + # 判断是否是最近的loop + if loop_distance < min_loop_distance: + min_loop_distance = loop_distance + nearest_loop = loop + + return nearest_boundary, nearest_loop + + +def update_hover(context, event): + if g.is_changing_mesh: + g.hovering_boundary = None + g.hovering_loop = None + + g.is_redraw_hovering = True + return + + current_timestamp = time.time() + if current_timestamp - g.last_update_hovering_timestamp < (1 / 10): + return + + # 两个方案,1.boundary的mid_co投影到2d,获取最近的,然后loop挑出b相邻的,再根据mid_co取2d最近的 2.ray_cast,没结果则用方案一,有结果,取距离该点最近的boundary的mid_co,然后再相邻取loop + + region = context.region + rv3d = context.region_data + mouse_co_2d = (event.mouse_region_x, event.mouse_region_y) + ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, mouse_co_2d) + ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, mouse_co_2d) + + closest_co = None + distance_min = float("inf") + for bvh_tree in g.snap_bvh_trees: + ray_cast_co, _, _, distance = bvh_tree.ray_cast(ray_origin, ray_direction) + if distance and distance < distance_min: + distance_min = distance + closest_co = ray_cast_co + + hovering_boundary = None + hovering_loop = None + if closest_co: + hovering_boundary, hovering_loop = get_nearest_boundary_and_loop_by_co_3d( + closest_co + ) + + else: + # boundary + hovering_boundary, hovering_loop = get_nearest_boundary_and_loop_by_co_2d( + context, mouse_co_2d + ) + + g.hovering_boundary = hovering_boundary + g.hovering_loop = hovering_loop + + g.last_update_hovering_timestamp = current_timestamp + + g.is_redraw_hovering = True + + +classes = (EasyPatchModalOperator, EasyPatchSnapSelectedToSurfaceOperator) diff --git a/pattern.py b/pattern.py new file mode 100644 index 0000000..3f7c206 --- /dev/null +++ b/pattern.py @@ -0,0 +1,1662 @@ +from __future__ import annotations +import bmesh +import numpy as np +import os +import sys +import copy + +from . import loop # 不要调用,仅作为提示 +from .utils import functions +from . import g + +lib_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "utils")) +sys.path.append(lib_path) +from .utils import pulp +from .utils.trans import t + + +class Pattern: + def __init__( + self, + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info={}, + ): + self.name = name + self.A = A + self.b_r = b_r + self.loop = None + self.pattern_check = pattern_check + self.vert_coords = vert_coords + self.edge_idxs = edge_idxs + self.face_idxs = face_idxs + self.split_vert_idxs = split_vert_idxs + self.additional_edge_flow_info = additional_edge_flow_info + + self.op_instance = None + self.rotation = 0 + self.verts = [] + self.edges = [] + self.faces = [] + self.interior_verts = [] + self.interior_edges = [] + self.solution = None + + @property + def boundary_num(self): + return len(self.b_r) + + def remove_mesh(self): + interior_verts = self.interior_verts + interior_edges = self.interior_edges + faces = self.faces + + # 先置为空再删除,防止绘图先获取 + self.interior_verts = [] + self.interior_edges = [] + + functions.remove_bm_faces(g.obj_bm, faces) + functions.remove_bm_edges(g.obj_bm, interior_edges) + functions.delete_bm_verts(g.obj_bm, interior_verts) + bmesh.update_edit_mesh(g.obj_me) + + def create_mesh(self, pattern_solution, rotation): + functions.log("creating patern mesh") + vert_coords = self.vert_coords + edge_idxs = self.edge_idxs + face_idxs = self.face_idxs + split_vert_idxs = self.split_vert_idxs + + # pattern_solution符合input,需要旋转符合pattern,才能合并边缘 + padding_list = functions.rotate_list( + pattern_solution[: self.boundary_num], rotation + ) + + # 用于检查形状 + rotated_boundaries_verts = [ + boundary.verts if d else boundary.verts[::-1] + for boundary, d in functions.rotate_list(self.loop.b_and_d_list, rotation) + ] + patch_boundary_shape = [len(i) for i in rotated_boundaries_verts] + + me = g.obj_me + bm = g.obj_bm + + # 创建顶点 + init_verts = [] + for idx, coord in enumerate(vert_coords): + vert = bm.verts.new(coord) + init_verts.append(vert) + split_verts = [init_verts[idx] for idx in split_vert_idxs] + + # 创建网格前确保顶点索引更新 + bm.verts.ensure_lookup_table() + + # 创建边 + init_edges = [] + for edge_indices in edge_idxs: + edge = bm.edges.new( + (init_verts[edge_indices[0]], init_verts[edge_indices[1]]) + ) + init_edges.append(edge) + + # 创建面 + init_faces = [] + for face_indices in face_idxs: + face = bm.faces.new([init_verts[i] for i in face_indices]) + init_faces.append(face) + + # 细分创建附加边流 + misc_edge_flow_verts = [] + misc_edge_flow_edges = [] + misc_edge_flow_faces = [] + for key, idx in self.additional_edge_flow_info.items(): + cut_num = 0 + edge = init_edges[idx] + if key == "x": + cut_num = pattern_solution[self.boundary_num] + elif key == "y": + cut_num = pattern_solution[self.boundary_num + 1] + elif key == "z": + cut_num = pattern_solution[self.boundary_num + 2] + else: + continue + result = bmesh.ops.subdivide_edgering( + bm, + edges=functions.edge_loops(bm, edge), + cuts=int(cut_num), + profile_shape="INVERSE_SQUARE", + profile_shape_factor=0.0, + ) + for face in result["faces"]: + for vert in face.verts: + misc_edge_flow_verts.append(vert) + for edge in face.edges: + misc_edge_flow_edges.append(edge) + misc_edge_flow_faces.append(face) + + # 挤出 + misc_extruded_subdivide_verts = [] + misc_extruded_subdivide_edges = [] + misc_extruded_subdivide_faces = [] + for i, padding_num in enumerate(padding_list): + if padding_num == 0: + continue + start_split_vert = split_verts[i] + if i + 1 == len(padding_list): + end_split_vert = split_verts[0] + else: + end_split_vert = split_verts[i + 1] + + boundary_edges = [] + # 寻找需要挤出的边,2个边组的情况另算 + # 这里是在细分之后,寻找方法是分割点寻找最近点, + if self.loop.length >= 3: + _, boundary_edges = functions.dijkstra( + bm, start_split_vert, end_split_vert + ) + + elif self.loop.length == 2: + # 第一个是边数多的,因为长度较短 + first_boundary_verts, first_boundary_edges = functions.dijkstra( + bm, + start_split_vert, + end_split_vert, + exclude_edges=[], + is_along_boundary=True, + ) + second_boundary_verts, second_boundary_edges = functions.dijkstra( + bm, + end_split_vert, + start_split_vert, + exclude_edges=first_boundary_edges, + is_along_boundary=True, + ) + # 根据旋转赋值长短边 + # pattern默认第一个边是下面那一个,也就是边数多的那一个 + # dijk获取的边第一个是边数少的那个 + if i == 0: + boundary_edges = second_boundary_edges + else: + boundary_edges = first_boundary_edges + + # for i in bm.verts: + # i.select = False + # start_split_vert.select = True + # end_split_vert.select = True + # bmesh.update_edit_mesh(me) + # return + + else: + raise Exception("Ivalide boundary num") + + extruded = bmesh.ops.extrude_edge_only(bm, edges=boundary_edges) + extruded_verts = [ + v for v in extruded["geom"] if isinstance(v, bmesh.types.BMVert) + ] + extruded_edges = [ + v for v in extruded["geom"] if isinstance(v, bmesh.types.BMEdge) + ] + extruded_faces = [ + v for v in extruded["geom"] if isinstance(v, bmesh.types.BMFace) + ] + misc_extruded_subdivide_verts += extruded_verts + misc_extruded_subdivide_edges += extruded_edges + misc_extruded_subdivide_faces += extruded_faces + + # 找新分割点 + new_start_split_vert = None + new_end_split_vert = None + len_1 = float("inf") + len_2 = float("inf") + for vert in extruded_verts: + new_len_1 = len(functions.dijkstra(bm, start_split_vert, vert)[0]) + if new_len_1 < len_1: + len_1 = new_len_1 + new_start_split_vert = vert + new_len_2 = len(functions.dijkstra(bm, end_split_vert, vert)[0]) + if new_len_2 < len_2: + len_2 = new_len_2 + new_end_split_vert = vert + + # 更新找分割点 + for idx, vert in enumerate(split_verts): + if vert == start_split_vert: + split_verts[idx] = new_start_split_vert + if vert == end_split_vert: + split_verts[idx] = new_end_split_vert + + # 细分挤出,作为作为挤出段数 + if padding_num > 1: + first_extrudeedge = functions.dijkstra( + bm, start_split_vert, new_start_split_vert + )[1][0] + + result = bmesh.ops.subdivide_edgering( + bm, + edges=functions.edge_loops(bm, first_extrudeedge), + cuts=int(padding_num - 1), + profile_shape="INVERSE_SQUARE", + profile_shape_factor=0.0, + ) + + for face in result["faces"]: + for vert in face.verts: + misc_extruded_subdivide_verts.append(vert) + for edge in face.edges: + misc_extruded_subdivide_edges.append(edge) + misc_extruded_subdivide_faces.append(face) + + # 不能使用edge,获取内部边,因合并的问题,当内部edge连接到了要被合并的顶点,则该edge会被新的替代, + # 去除不存在和重复 + edges = set(init_edges).union( + misc_extruded_subdivide_edges, misc_edge_flow_edges + ) + verts = set(init_verts).union( + misc_extruded_subdivide_verts, misc_edge_flow_verts + ) + faces = set(init_faces).union( + misc_extruded_subdivide_faces, misc_edge_flow_faces + ) + + # inter绘图用,verts删除用 + # # 去除不存在的 + # 合并点会破坏点和边的索引,需要用面找边 + self.faces = [face for face in faces if face in bm.faces] + + # 也要记录vert,因为重建boundary会把面破坏掉, + verts, edges = functions.get_verts_and_edges_from_faces(g.obj_bm, self.faces) + verts = [vert for vert in verts if vert in bm.verts] + edges = [edge for edge in edges if edge in bm.edges] + + self.edges = edges + self.verts = verts + self.interior_verts = [vert for vert in verts if not vert.is_boundary] + + # 获取将要合并的顶点列表,单独处理2个边的情况 + pattern_boundaries_verts = [] + if len(self.loop.shape) >= 3: + for i in range(len(split_verts)): + start_split_vert = split_verts[i] + if i + 1 == len(padding_list): + end_split_vert = split_verts[0] + else: + end_split_vert = split_verts[i + 1] + boundary_verts, _ = functions.dijkstra( + bm, start_split_vert, end_split_vert + ) + pattern_boundaries_verts.append(boundary_verts) + + elif len(self.loop.shape) == 2: + first_boundary_start_vert = split_verts[0] + first_boundary_end_vert = split_verts[1] + first_boundary_verts, first_boundary_edges = functions.dijkstra( + bm, + first_boundary_start_vert, + first_boundary_end_vert, + exclude_edges=[], + is_along_boundary=True, + ) + + second_boundary_start_vert = split_verts[1] + second_boundary_end_vert = split_verts[0] + second_boundary_verts, second_boundary_edges = functions.dijkstra( + bm, + second_boundary_start_vert, + second_boundary_end_vert, + exclude_edges=first_boundary_edges, + is_along_boundary=True, + ) + + pattern_boundaries_verts = [ + first_boundary_verts, + second_boundary_verts, + ] + + # 两条边无法区分顺序,自行翻转顺序 + pattern_boundary_shape = [len(i) for i in pattern_boundaries_verts] + if ( + len(self.loop.shape) == 2 + and pattern_boundary_shape != patch_boundary_shape + ): + pattern_boundaries_verts = pattern_boundaries_verts[::-1] + + else: + raise Exception("Ivalid boundary num") + + # 检查形状 + pattern_boundary_shape = [len(i) for i in pattern_boundaries_verts] + if len(self.loop.shape) >= 2 and pattern_boundary_shape != patch_boundary_shape: + self.remove_mesh() + msg = f"\n{self.name}\ninput {self.loop.shape}\nsolution {pattern_solution}\nrotation {rotation}\npattern shape {pattern_boundary_shape}\npatch shape {patch_boundary_shape}\nshape not match" + raise Exception(msg) + + for i, (verts_1, verts_2) in enumerate( + zip(pattern_boundaries_verts, rotated_boundaries_verts) + ): + for j, (vert_1, vert_2) in enumerate(zip(verts_1, verts_2)): + if j == len(verts_1) - 1: + continue + bmesh.ops.pointmerge( + bm, verts=[vert_2, vert_1], merge_co=vert_2.co + ) # 会破坏点线面对象和索引 + + # 要在合并顶点后获取edges,合并会破坏edge对象 + verts, edges = functions.get_verts_and_edges_from_faces(g.obj_bm, self.faces) + self.interior_edges = [ + edge for edge in edges if edge in bm.edges and edge not in self.loop.edges + ] + self.interior_verts = [ + vert + for vert in verts + if vert in bm.verts and vert not in self.loop.boundary_verts_walk + ] + + functions.smooth_verts_by_normal(g.obj, bm, self.verts) + + # 更新网格 + # 计算法线方向 + bmesh.ops.recalc_face_normals( + bm, faces=list([i for i in faces if i in bm.faces]) + ) + if g.is_snap_surface: + functions.snap_verts_to_surface_along_normal( + functions.get_edit_obj(), + g.obj_bm, + self.interior_verts, + g.snap_bvh_trees, + allow_opposite_direction=False, + ) + bmesh.update_edit_mesh(me) + bm.normal_update() + + # def clear(self): + # bmesh.ops.delete( + # self.op_instance.bm, + # geom=self.interior_verts, + # context="VERTS", + # ) + + # bmesh.update_edit_mesh(self.op_instance.me) + + # self.boundary_verts = [] + # self.interior_verts = [] + + def validate_input_and_get_rotation(self, boundary_shape): + all_reduced_inputs = functions.get_all_reduced_inputs(boundary_shape) + boundary_shape = all_reduced_inputs[0] + + rotation = None + boundary_shape_temp = None + + for r in range(len(boundary_shape)): + boundary_shape_temp = functions.rotate_list(boundary_shape, r) + rotation = r + + for a, b in zip(self.pattern_check, boundary_shape_temp): + if isinstance(a, int) and a != b: + rotation = None + continue + + if callable(a): + result = a(boundary_shape_temp) + if isinstance(result, int) and result < 0: + rotation = None + continue + + if isinstance(result, float) and ( + result < 0 or not result.is_integer() + ): + rotation = None + continue + + if rotation is None: + continue + else: + # 匹配 + functions.log(f"reduce: {boundary_shape}") + functions.log(f"rotation: {r}") + return r + + return None + + def solve_pattern_lp( + self, boundary_shape, rotation, constraint_list=[], is_suggest=False + ): + # TODO: ilp没加整数校验 + boundary_input = functions.rotate_list(boundary_shape, rotation) + + boundary_input_len = len(boundary_input) + constraint_list = ( + functions.rotate_list(constraint_list[:boundary_input_len], rotation) + + constraint_list[boundary_input_len:] + ) + + functions.log(f"solver shape input: {boundary_input}") + functions.log(f"solver constraint input: {constraint_list}") + functions.log(f"solver rotation input: {rotation}") + + A = np.array(self.A) + b_l = np.array(boundary_input) + b_r = np.array(self.b_r) + b = b_l - b_r + + num_cols = A.shape[1] # 列,变量数,p和边流 + num_rows = A.shape[0] # 行,即约束数,p + indices = list(range(num_cols)) + + p = [ + pulp.LpVariable(f"p_{i}", lowBound=0, cat=pulp.LpInteger) + for i in range(num_rows) + ] + + edge_flow_vars = [] + if num_cols > num_rows: + for i in range(num_cols - num_rows): + var_name = None + if i == 0: + var_name = "x" + if i == 1: + var_name = "y" + if i == 2: + var_name = "z" + + if var_name: + edge_flow_var = pulp.LpVariable( + var_name, lowBound=0, cat=pulp.LpInteger + ) + edge_flow_vars.append(edge_flow_var) + + vars = p + edge_flow_vars + + if is_suggest: + # 软约束 + prob = pulp.LpProblem("ProbLp", pulp.LpMinimize) + dev_pos_list = [ + pulp.LpVariable(f"dev_{i}_pos", lowBound=0) for i in range(num_rows) + ] + dev_neg_list = [ + pulp.LpVariable(f"dev_{i}_neg", lowBound=0) for i in range(num_rows) + ] + + prob += pulp.lpSum(dev_pos_list) + pulp.lpSum(dev_neg_list) + + for i in range(num_rows): + prob += ( + pulp.lpSum([A[i][j] * vars[j] for j in indices]) + == b[i] + dev_pos_list[i] - dev_neg_list[i] + ) + + # 用户附加约束 + for i, j in zip(vars, constraint_list): + if j is None: + continue + prob += i == j + status = prob.solve(pulp.PULP_CBC_CMD(msg=0)) + + # 求的时候建议了旋转,解的时候转回来 + solution = [ + i.value() for i in functions.rotate_list(dev_pos_list, -rotation) + ] + [i.value() for i in functions.rotate_list(dev_neg_list, -rotation)] + # solution = [i.value() for i in dev_pos_list] + [ + # i.value() for i in dev_neg_list + # ] + + else: + # 硬约束 + prob = pulp.LpProblem("ProbLp", pulp.LpMaximize) + prob += pulp.lpSum(p) + + b = b_l - b_r + for i in range(num_rows): + prob += pulp.lpSum([A[i][j] * vars[j] for j in indices]) == b[i] + + # 用户附加约束 + for i, j in zip(vars, constraint_list): + if j is None: + continue + prob += i == j + + status = prob.solve(pulp.PULP_CBC_CMD(msg=0)) + + solution = [ + i.value() + for i in ( + functions.rotate_list(vars[:boundary_input_len], -rotation) + + vars[boundary_input_len:] + ) + ] + # solution = [i.value() for i in vars] + + if status != 1: + functions.log("no solution") + return None + + # 如果edgeflow缺了,手动附加 + if len(vars) - len(solution) > 0: + solution += [0.0 for _ in range(len(vars) - len(solution))] + + if is_suggest: + functions.log(f"suggest: {solution}") + else: + functions.log(f"solution: {solution}") + + return solution + + +# 这里写的很乱,理一理 +def recreate_pattern(loop): + if not loop: + return None + + if loop.pattern: + loop.pattern.remove_mesh() + loop.pattern = None + + loop.solver_msg = "" + loop.suggest_solution = None + + # 检查奇偶 + if sum(loop.shape) % 2 != 0: + loop.solver_msg = t("not_even_num_edges") + return None + + boundary_shape = loop.shape + pattern_instance = None + rotation = None + pattern_solution = None + + constraint_pattern = loop.solver_constraint_pattern + constraint_list = loop.solver_constraint_list + + # 有pattern约束且没有rotation约束,则搜索rotation,否则rotation就是当前约束 + if constraint_pattern: + pattern_instance = copy.deepcopy(constraint_pattern) + constraint_rotation = loop.solver_constraint_rotation + # 没有约束但rotation没定义,则遍历搜索rotation,因此用户调整var的时候,如果没有rotation约束,会搜索n次,很慢 + if constraint_rotation is None: + # 遍历rotation计算n次 + for r in range(len(loop.shape)): + pattern_solution = pattern_instance.solve_pattern_lp( + loop.shape, r, constraint_list=constraint_list + ) + if pattern_solution is not None: + rotation = r + break + else: + pattern_solution = pattern_instance.solve_pattern_lp( + loop.shape, constraint_rotation, constraint_list=constraint_list + ) + rotation = constraint_rotation + + else: + # 初始化遍历寻找符合的pattern + for pattern in pattern_list: + # 匹配长度 + if len(boundary_shape) != pattern.boundary_num: + continue + + rotation = pattern.validate_input_and_get_rotation(boundary_shape) + if rotation is None: + continue + + pattern_instance = copy.deepcopy(pattern) + break + + if not pattern_instance: + loop.solver_msg = t("no_match_pattern") + return None + + pattern_solution = pattern_instance.solve_pattern_lp(loop.shape, rotation) + + # 无解,给出建议 + if pattern_solution is None: + # 这里的rotation变量是承接上文的,有可能是None + if rotation is not None: + suggest_rotation = rotation + else: + # 如果是带约束,到这一步之前会执行n次ilp,因为要解决旋转问题,如果不带约束,则在这之前只执行了一次 + suggest_rotation = get_suggest_rotation(pattern_instance, loop.shape) + + functions.log(f"suggest rotation: suggest_rotation") + suggest_solution = pattern_instance.solve_pattern_lp( + loop.shape, + suggest_rotation, + constraint_list=constraint_list, + is_suggest=True, + ) + if suggest_solution: + loop.suggest_solution = suggest_solution + + loop.solver_msg = t("no_solution") + return None + + pattern_instance.rotation = rotation + + pattern_instance.loop = loop + pattern_instance.solution = pattern_solution + + pattern_instance.create_mesh(pattern_solution, rotation) + + return pattern_instance + + +# 没有rotation的情况给建议,先找rotation,通过与pattern_check的差异查找 +def get_suggest_rotation(pattern, boundary_shape): + all_reduced_inputs = functions.get_all_reduced_inputs( + boundary_shape, is_remove_duplicated_rotation=False + ) + pattern_check = pattern.pattern_check + suggest_rotation = 0 + dev_min = float("inf") + for boundary_shape in all_reduced_inputs: + for rotation in range(len(boundary_shape)): + boundary_shape_temp = functions.rotate_list(boundary_shape, rotation) + dev = 0 + for a, b in zip(pattern_check, boundary_shape_temp): + if isinstance(a, int): + dev += abs(a - b) + + if callable(a): + result = a(boundary_shape_temp) + dev += abs(result) + + if dev < dev_min: + dev_min = dev + suggest_rotation = rotation + + return suggest_rotation + + +def try_create_pattern(): + pass + + +pattern_list = [] + +# 2-0 +name = "2-0" +A = [ + [0, 2, 2, 1], + [2, 0, 0, 1], +] +b_r = [3, 1] +pattern_check = [lambda i: (i[0] - i[1] - 2) / 2, lambda i: i[1] - 1] +vert_coords = [[2.0, 1.0, 0.0], [-2.0, 1.0, 0.0], [1.0, -1.0, 0.0], [-1.0, -1.0, 0.0]] +edge_idxs = [[3, 2], [0, 1], [2, 0], [1, 3]] +face_idxs = [[3, 2, 0, 1]] +split_vert_idxs = (1, 0) +additional_edge_flow_info = {"x": 3, "y": 0} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +# 2-1 +name = "2-1" +A = [ + [0, 2, 1, 1], + [2, 0, 1, 1], +] +b_r = [2, 2] +pattern_check = [lambda i: 0.999 * (i[0] - i[1]), lambda i: i[0] - 2] +vert_coords = [ + [-1.4142135381698608, 0.0, 0.0], + [0.0, -2.4142136573791504, 0.0], + [0.0, 1.4142135381698608, 0.0], + [1.4142135381698608, 0.0, 0.0], +] +edge_idxs = [[0, 1], [2, 0], [3, 2], [1, 3]] +face_idxs = [[0, 1, 3, 2]] +split_vert_idxs = [0, 3] +additional_edge_flow_info = {"x": 0, "y": 3} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +# 3-0 +name = "3-0" +A = [ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0], +] +b_r = [2, 1, 1] +pattern_check = [2, 1, 1] +vert_coords = [ + (0.0, 1.0, 0.0), + (-0.8660253882408142, -0.5, 0.0), + (0.8660253882408142, -0.5, 0.0), + (0.0, -0.5, 0.0), +] +edge_idxs = [(1, 0), (3, 1), (0, 2), (2, 3)] +face_idxs = [[1, 3, 2, 0]] +split_vert_idxs = [1, 2, 0] +additional_edge_flow_info = {} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + + +# 3-1 +name = "3-1" +A = ( + (0, 1, 1, 2), + (1, 0, 1, 0), + (1, 1, 0, 0), +) +b_r = [4, 1, 1] +pattern_check = [lambda i: (i[0] - 4) / 2, 1, 1] +vert_coords = [ + (0.0, 1.0, 0.0), + (-0.8660253882408142, -0.5, 0.0), + (0.8660253882408142, -0.5, 0.0), + (0.0, 0.0, 0.0), + (0.0, -0.5, 0.0), + (0.4330126941204071, -0.5, 0.0), + (-0.4330126941204071, -0.5, 0.0), +] +edge_idxs = [(1, 0), (6, 1), (0, 2), (4, 6), (5, 4), (2, 5), (3, 5), (3, 6), (0, 3)] +face_idxs = [[4, 5, 3, 6], [1, 6, 3, 0], [2, 0, 3, 5]] +split_vert_idxs = (1, 2, 0) +additional_edge_flow_info = {"x": 8} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + + +# 4-0 +name = "4-0" +A = ( + (0, 1, 0, 1), + (1, 0, 1, 0), + (0, 1, 0, 1), + (1, 0, 1, 0), +) +b_r = (1, 1, 1, 1) +pattern_check = (1, 1, 1, 1) +vert_coords = [(1.0, 1.0, 0.0), (-1.0, 1.0, 0.0), (1.0, -1.0, 0.0), (-1.0, -1.0, 0.0)] +edge_idxs = [(2, 0), (3, 2), (0, 1), (1, 3)] +face_idxs = [[3, 2, 0, 1]] +split_vert_idxs = [3, 2, 0, 1] +additional_edge_flow_info = {} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +# 4-1 +name = "4-1" +A = ( + (0, 1, 0, 1, 1), + (1, 0, 1, 0, 1), + (0, 1, 0, 1, 0), + (1, 0, 1, 0, 0), +) +b_r = (2, 2, 1, 1) +# pattern_check = ( +# lambda i: 0.5 * (i[0] != i[1]), +# lambda i: i[1] - 2, +# 1, +# 1, +# ) +pattern_check = ( + lambda i: 0.999 * (i[0] - i[1]), + lambda i: i[1] - 2, + 1, + 1, +) +vert_coords = [ + (1.0, 1.0, 0.0), + (-1.0, 1.0, 0.0), + (1.0, -1.0, 0.0), + (-1.0, -1.0, 0.0), + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (0.0, -1.0, 0.0), +] +edge_idxs = [(0, 1), (6, 2), (1, 3), (5, 0), (4, 6), (4, 5), (2, 5), (3, 6), (1, 4)] +face_idxs = [[6, 4, 5, 2], [0, 5, 4, 1], [3, 1, 4, 6]] +split_vert_idxs = (3, 2, 0, 1) +additional_edge_flow_info = {"x": 8} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + + +# 4-2 +name = "4-2" +A = ( + (0, 1, 0, 1, 1, 1), + (1, 0, 1, 0, 1, 0), + (0, 1, 0, 1, 0, 0), + (1, 0, 1, 0, 0, 1), +) +b_r = (3, 1, 1, 1) +# pattern_check = [ +# lambda i: i[1] - 1, +# lambda i: i[3] - 1, +# 1, +# lambda i: 0.5 * (i[0] - i[1] - i[3] != 1), +# ] +pattern_check = [ + lambda i: i[1] - 1, + lambda i: i[3] - 1, + 1, + lambda i: 0.999 * (i[0] - i[1] - i[3] - 1), +] +vert_coords = [ + (0.3333333134651184, -1.0, 0.0), + (-0.3333333134651184, -1.0, 0.0), + (1.0, 1.0, 0.0), + (-1.0, 1.0, 0.0), + (1.0, -1.0, 0.0), + (-1.0, -1.0, 0.0), +] +edge_idxs = [(3, 5), (0, 4), (1, 0), (5, 1), (1, 2), (4, 2), (2, 3)] +face_idxs = [[3, 2, 1, 5], [0, 1, 2, 4]] + +split_vert_idxs = (5, 4, 2, 3) +additional_edge_flow_info = {"x": 2, "y": 4} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +name = "4-2-i" +# pattern_check = [ +# lambda i: 0.5 * (i[-1] - i[-2] - i[0] != 1), +# 1, +# lambda i: i[0] - 1, +# lambda i: i[-2] - 1, +# ] +pattern_check = [ + lambda i: 0.999 * (i[-1] - i[-2] - i[0] - 1), + 1, + lambda i: i[0] - 1, + lambda i: i[-2] - 1, +] +pattern_list.append( + Pattern( + name, + functions.flip_A(A), + b_r[::-1], + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + (split_vert_idxs[0],) + split_vert_idxs[:0:-1], + additional_edge_flow_info, + ) +) + +# 4-3 +name = "4-3" +A = ( + (0, 1, 0, 1, 2, 1), + (1, 0, 1, 0, 0, 0), + (0, 1, 0, 1, 0, 1), + (1, 0, 1, 0, 0, 0), +) +b_r = (3, 1, 1, 1) +pattern_check = [lambda i: (i[0] - i[2] - 2) / 2, 1, lambda i: i[2] - 1, 1] +vert_coords = [ + (1.0, 1.0, 0.0), + (-1.0, 1.0, 0.0), + (1.0, -1.0, 0.0), + (-1.0, -1.0, 0.0), + (-0.3333333134651184, -1.0, 0.0), + (0.3333333134651184, -1.0, 0.0), + (-0.3333333134651184, 0.0, 0.0), + (0.3333333134651184, 0.0, 0.0), +] +edge_idxs = [ + (5, 2), + (1, 3), + (0, 1), + (2, 0), + (3, 4), + (4, 5), + (6, 7), + (4, 6), + (5, 7), + (0, 7), + (1, 6), +] +face_idxs = [[5, 7, 6, 4], [2, 0, 7, 5], [0, 1, 6, 7], [3, 4, 6, 1]] +split_vert_idxs = (3, 2, 0, 1) +additional_edge_flow_info = {"x": 10, "y": 6} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + + +# 4-4 +name = "4-4" +A = ( + (0, 1, 0, 1, 2, 1), + (1, 0, 1, 0, 0, 1), + (0, 1, 0, 1, 0, 0), + (1, 0, 1, 0, 0, 0), +) +b_r = (4, 2, 1, 1) +pattern_check = [lambda i: (i[0] - i[1] - 2) / 2, lambda i: i[1] - 2, 1, 1] +vert_coords = [ + (-0.5, 0.0, 0.0), + (0.5, -0.5, 0.0), + (0.0, 0.0, 0.0), + (0.5, -1.0, 0.0), + (0.0, -1.0, 0.0), + (-0.5, -1.0, 0.0), + (1.0, 1.0, 0.0), + (-1.0, 1.0, 0.0), + (1.0, -1.0, 0.0), + (-1.0, -1.0, 0.0), + (1.0, 0.0, 0.0), +] +edge_idxs = [ + (3, 8), + (4, 3), + (2, 4), + (0, 2), + (10, 6), + (5, 4), + (9, 5), + (0, 7), + (0, 5), + (1, 10), + (2, 6), + (7, 9), + (6, 7), + (1, 2), + (1, 3), + (8, 10), +] +face_idxs = [ + [3, 1, 10, 8], + [6, 10, 1, 2], + [4, 5, 0, 2], + [4, 2, 1, 3], + [0, 7, 6, 2], + [9, 7, 0, 5], +] +split_vert_idxs = (9, 8, 6, 7) +additional_edge_flow_info = {"x": 7, "y": 13} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +name = "4-4-i" +pattern_check = [1, 1, lambda i: i[-2] - 2, lambda i: (i[-1] - i[-2] - 2) / 2] +pattern_list.append( + Pattern( + name, + functions.flip_A(A), + b_r[::-1], + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + (split_vert_idxs[0],) + split_vert_idxs[:0:-1], + additional_edge_flow_info, + ) +) + +# # 4-circ +# name = "4-circ" +# A = ( +# (0, 1, 0, 1, 0), +# (1, 0, 1, 0, 0), +# (0, 1, 0, 1, 0), +# (1, 0, 1, 0, 0), +# ) +# b_r = (1, 1, 1, 1) +# pattern_check = (1, 1, 1, 1) +# vert_coords = [ +# (-1.0, -1.0, 0.0), +# (1.0, -1.0, 0.0), +# (-1.0, 1.0, 0.0), +# (1.0, 1.0, 0.0), +# (-0.5, -0.5, 0.0), +# (0.5, -0.5, 0.0), +# (-0.5, 0.5, 0.0), +# (0.5, 0.5, 0.0), +# ] +# edge_idxs = [ +# (2, 0), +# (0, 1), +# (1, 3), +# (3, 2), +# (6, 4), +# (4, 5), +# (5, 7), +# (7, 6), +# (0, 4), +# (6, 2), +# (7, 3), +# (1, 5), +# ] +# face_idxs = [ +# [0, 2, 3, 1], +# [4, 5, 7, 6], +# [2, 0, 4, 6], +# [3, 2, 6, 7], +# [0, 1, 5, 4], +# [1, 3, 7, 5], +# ] +# split_vert_idxs = (0, 1, 3, 2) +# additional_edge_flow_info = {"x": 8} +# pattern_list.append( +# Pattern( +# name, +# A, +# b_r, +# pattern_check, +# vert_coords, +# edge_idxs, +# face_idxs, +# split_vert_idxs, +# additional_edge_flow_info, +# ) +# ) + + +# 5-0 +name = "5-0" +A = ( + (0, 1, 0, 0, 1), + (1, 0, 1, 0, 0), + (0, 1, 0, 1, 0), + (0, 0, 1, 0, 1), + (1, 0, 0, 1, 0), +) +b_r = (2, 1, 1, 1, 1) +pattern_check = [2, 1, 1, 1, 1] +vert_coords = [ + (0.0, 1.0, 0.0), + (-0.9510565400123596, 0.30901700258255005, 0.0), + (-0.5877852439880371, -0.80901700258255, 0.0), + (0.5877852439880371, -0.80901700258255, 0.0), + (0.9510565400123596, 0.30901700258255005, 0.0), + (0.0, -0.80901700258255, 0.0), +] +edge_idxs = [(1, 0), (2, 1), (5, 2), (4, 3), (0, 4), (3, 5), (0, 5)] +face_idxs = [[1, 2, 5, 0], [4, 0, 5, 3]] +split_vert_idxs = (2, 3, 4, 0, 1) +additional_edge_flow_info = {} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +# 5-1 +name = "5-1" +A = ( + (0, 1, 0, 0, 1, 1), + (1, 0, 1, 0, 0, 1), + (0, 1, 0, 1, 0, 0), + (0, 0, 1, 0, 1, 0), + (1, 0, 0, 1, 0, 0), +) +b_r = (2, 1, 1, 1, 1) +# pattern_check = [lambda i: i[1] - 1, lambda i: 0.5 * (i[0] - i[1] != 1), 1, 1, 1] +pattern_check = [lambda i: i[1] - 1, lambda i: 0.999 * (i[0] - i[1] - 1), 1, 1, 1] +vert_coords = [ + (0.0, -0.80901700258255, 0.0), + (0.9510565400123596, 0.30901700258255005, 0.0), + (0.5877852439880371, -0.80901700258255, 0.0), + (-0.5877852439880371, -0.80901700258255, 0.0), + (-0.9510565400123596, 0.30901700258255005, 0.0), + (0.0, 1.0, 0.0), +] +edge_idxs = [(1, 3), (1, 2), (0, 3), (3, 4), (2, 0), (4, 5), (5, 1)] +face_idxs = [[5, 4, 3, 1], [0, 2, 1, 3]] +split_vert_idxs = (3, 2, 1, 5, 4) +additional_edge_flow_info = {"x": 2} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) +name = "5-1-i" +# pattern_check = [1, 1, 1, lambda i: 0.5 * (i[-1] - i[-2] != 1), lambda i: i[-2] - 1] +pattern_check = [1, 1, 1, lambda i: 0.999 * (i[-1] - i[-2] - 1), lambda i: i[-2] - 1] +pattern_list.append( + Pattern( + name, + functions.flip_A(A), + b_r[::-1], + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + (split_vert_idxs[0],) + split_vert_idxs[:0:-1], + additional_edge_flow_info, + ) +) + +# 5-2 +name = "5-2" +A = ( + (0, 1, 0, 0, 1, 2), + (1, 0, 1, 0, 0, 0), + (0, 1, 0, 1, 0, 0), + (0, 0, 1, 0, 1, 0), + (1, 0, 0, 1, 0, 0), +) +b_r = (4, 1, 1, 1, 1) +pattern_check = (lambda i: (i[0] - 4) / 2, 1, 1, 1, 1) +vert_coords = [ + (0.29389262199401855, -0.80901700258255, 0.0), + (0.9510565400123596, 0.30901700258255005, 0.0), + (0.5877852439880371, -0.80901700258255, 0.0), + (-0.5877852439880371, -0.80901700258255, 0.0), + (-0.9510565400123596, 0.30901700258255005, 0.0), + (0.0, 1.0, 0.0), + (0.0, -0.80901700258255, 0.0), + (-0.29389262199401855, -0.80901700258255, 0.0), + (0.0, 0.0, 0.0), + (0.0, -0.404508501291275, 0.0), +] +edge_idxs = [ + (7, 3), + (5, 1), + (0, 6), + (3, 4), + (2, 0), + (4, 5), + (1, 2), + (6, 7), + (7, 9), + (0, 9), + (8, 9), + (3, 8), + (2, 8), + (5, 8), +] +face_idxs = [[7, 9, 8, 3], [0, 2, 8, 9], [6, 0, 9, 7], [1, 5, 8, 2], [4, 3, 8, 5]] +split_vert_idxs = (3, 2, 1, 5, 4) +additional_edge_flow_info = {"x": 10} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +# 5-3 +name = "5-3" +A = ( + (0, 1, 0, 0, 1, 2, 1), + (1, 0, 1, 0, 0, 0, 1), + (0, 1, 0, 1, 0, 0, 0), + (0, 0, 1, 0, 1, 0, 0), + (1, 0, 0, 1, 0, 0, 0), +) +b_r = (5, 2, 1, 1, 1) +pattern_check = [lambda i: (i[0] - i[1] - 3) / 2, lambda i: i[1] - 2, 1, 1, 1] +vert_coords = [ + (-0.5089906454086304, 0.0, 0.0), + (-0.35267114639282227, -0.80901700258255, 0.0), + (-0.11755704879760742, -0.80901700258255, 0.0), + (0.11755704879760742, -0.80901700258255, 0.0), + (0.35267114639282227, -0.80901700258255, 0.0), + (0.9510565400123596, 0.30901700258255005, 0.0), + (0.5877852439880371, -0.80901700258255, 0.0), + (-0.5877852439880371, -0.80901700258255, 0.0), + (-0.9510565400123596, 0.30901700258255005, 0.0), + (0.0, 1.0, 0.0), + (-0.22425960004329681, 0.34856167435646057, 0.0), + (0.08892543613910675, 0.0, 0.0), + (0.31710362434387207, -0.24705025553703308, 0.0), + (0.769420862197876, -0.25, 0.0), +] +edge_idxs = [ + (3, 2), + (10, 0), + (4, 3), + (9, 5), + (11, 10), + (1, 7), + (7, 8), + (0, 1), + (8, 9), + (6, 4), + (2, 1), + (13, 6), + (2, 10), + (3, 11), + (12, 4), + (12, 11), + (5, 13), + (13, 12), + (11, 5), + (9, 10), + (0, 8), +] +face_idxs = [ + [10, 2, 1, 0], + [10, 11, 3, 2], + [11, 12, 4, 3], + [4, 12, 13, 6], + [12, 11, 5, 13], + [9, 5, 11, 10], + [10, 0, 8, 9], + [1, 7, 8, 0], +] +split_vert_idxs = (7, 6, 5, 9, 8) +additional_edge_flow_info = {"x": 20, "y": 15} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +name = "5-3-i" +pattern_check = [1, 1, 1, lambda i: i[-2] - 2, lambda i: (i[-1] - i[-2] - 3) / 2] +pattern_list.append( + Pattern( + name, + functions.flip_A(A), + b_r[::-1], + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + (split_vert_idxs[0],) + split_vert_idxs[:0:-1], + additional_edge_flow_info, + ) +) + + +# 6-0 +name = "6-0" +A = ( + (0, 1, 0, 0, 0, 1, 1), + (1, 0, 1, 0, 0, 0, 0), + (0, 1, 0, 1, 0, 0, 0), + (0, 0, 1, 0, 1, 0, 1), + (0, 0, 0, 1, 0, 1, 0), + (1, 0, 0, 0, 1, 0, 0), +) +b_r = (1, 1, 1, 1, 1, 1) +# pattern_check = [lambda i: i[0] - 1, 1, 1, lambda i: 0.5 * (i[0] != i[3]), 1, 1] +pattern_check = [lambda i: i[0] - 1, 1, 1, lambda i: 0.999 * (i[0] - i[3]), 1, 1] +vert_coords = [ + (1.0, 0.0, 0.0), + (0.5000000596046448, 0.8660253882408142, 0.0), + (-0.4999999701976776, 0.866025447845459, 0.0), + (-1.0, 0.0, 0.0), + (-0.5000000596046448, -0.8660253882408142, 0.0), + (0.4999999701976776, -0.866025447845459, 0.0), +] +edge_idxs = [(1, 0), (2, 1), (3, 2), (4, 3), (5, 4), (0, 5), (3, 0)] +face_idxs = [[4, 5, 0, 3], [1, 2, 3, 0]] +split_vert_idxs = (4, 5, 0, 1, 2, 3) +additional_edge_flow_info = {"x": 6} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +# 6-1 +name = "6-1" +A = ( + (0, 1, 0, 0, 0, 1, 1), + (1, 0, 1, 0, 0, 0, 1), + (0, 1, 0, 1, 0, 0, 0), + (0, 0, 1, 0, 1, 0, 0), + (0, 0, 0, 1, 0, 1, 0), + (1, 0, 0, 0, 1, 0, 0), +) +b_r = (2, 2, 1, 1, 1, 1) +# pattern_check = [lambda i: i[0] - 2, lambda i: 0.5 * (i[0] != i[1]), 1, 1, 1, 1] +pattern_check = [lambda i: i[0] - 2, lambda i: 0.999 * (i[0] - i[1]), 1, 1, 1, 1] +vert_coords = [ + (0.4999999701976776, -0.866025447845459, 0.0), + (-0.5000000596046448, -0.8660253882408142, 0.0), + (-1.0, 0.0, 0.0), + (-0.4999999701976776, 0.866025447845459, 0.0), + (0.5000000596046448, 0.8660253882408142, 0.0), + (1.0, 0.0, 0.0), + (0.0, -9.934107758624577e-09, 0.0), + (0.25, -0.4330127239227295, 0.0), + (0.75, -0.4330127239227295, 0.0), + (-2.9802322387695312e-08, -0.866025447845459, 0.0), +] +edge_idxs = [ + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (8, 0), + (9, 1), + (6, 7), + (7, 8), + (7, 9), + (5, 8), + (0, 9), + (1, 6), + (5, 6), + (3, 6), +] +face_idxs = [[9, 7, 6, 1], [8, 7, 9, 0], [7, 8, 5, 6], [4, 3, 6, 5], [2, 1, 6, 3]] +split_vert_idxs = (1, 0, 5, 4, 3, 2) +additional_edge_flow_info = {"x": 6} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + + +# 6-2 +name = "6-2" +A = ( + (0, 1, 0, 0, 0, 1, 2, 1), + (1, 0, 1, 0, 0, 0, 0, 0), + (0, 1, 0, 1, 0, 0, 0, 0), + (0, 0, 1, 0, 1, 0, 0, 1), + (0, 0, 0, 1, 0, 1, 0, 0), + (1, 0, 0, 0, 1, 0, 0, 0), +) +b_r = (3, 1, 1, 1, 1, 1) +pattern_check = [lambda i: i[3] - 1, 1, 1, lambda i: (i[0] - i[3] - 2) / 2, 1, 1] +vert_coords = [ + (0.16666662693023682, -0.38941529393196106, 0.0), + (-0.16666671633720398, -0.38941529393196106, 0.0), + (-0.16666671633720398, -0.866025447845459, 0.0), + (0.16666662693023682, -0.866025447845459, 0.0), + (0.4999999701976776, -0.866025447845459, 0.0), + (-0.5000000596046448, -0.8660253882408142, 0.0), + (-1.0, 0.0, 0.0), + (-0.4999999701976776, 0.866025447845459, 0.0), + (0.5000000596046448, 0.8660253882408142, 0.0), + (1.0, 0.0, 0.0), + (0.28053152561187744, 0.0, 0.0), + (-0.2805315852165222, 0.0, 0.0), +] +edge_idxs = [ + (11, 1), + (0, 1), + (3, 2), + (9, 4), + (0, 3), + (5, 11), + (6, 7), + (7, 8), + (8, 9), + (2, 5), + (1, 2), + (10, 11), + (5, 6), + (4, 3), + (10, 0), + (10, 4), + (8, 10), + (7, 11), +] +face_idxs = [ + [3, 0, 10, 4], + [4, 10, 8, 9], + [5, 11, 1, 2], + [1, 0, 3, 2], + [1, 11, 10, 0], + [7, 8, 10, 11], + [7, 11, 5, 6], +] +split_vert_idxs = (5, 4, 9, 8, 7, 6) +additional_edge_flow_info = {"x": 0, "y": 1} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + + +# 6-3 +name = "6-3" +A = ( + (0, 1, 0, 0, 0, 1, 2, 1), + (1, 0, 1, 0, 0, 0, 0, 1), + (0, 1, 0, 1, 0, 0, 0, 0), + (0, 0, 1, 0, 1, 0, 0, 0), + (0, 0, 0, 1, 0, 1, 0, 0), + (1, 0, 0, 0, 1, 0, 0, 0), +) +b_r = (4, 2, 1, 1, 1, 1) +pattern_check = [lambda i: i[1] - 2, lambda i: (i[0] - i[1] - 2) / 2, 1, 1, 1, 1] +vert_coords = [ + (0.75, -0.4330127239227295, 0.0), + (-2.9802322387695312e-08, -0.0053040385246276855, 0.0), + (-0.2500000298023224, -0.0053040385246276855, 0.0), + (-0.2500000298023224, -0.866025447845459, 0.0), + (-2.9802322387695312e-08, -0.866025447845459, 0.0), + (0.2499999701976776, -0.866025447845459, 0.0), + (0.4999999701976776, -0.866025447845459, 0.0), + (-0.5000000596046448, -0.8660253882408142, 0.0), + (-1.0, 0.0, 0.0), + (-0.4999999701976776, 0.866025447845459, 0.0), + (0.5000000596046448, 0.8660253882408142, 0.0), + (1.0, 0.0, 0.0), + (0.43703240156173706, -0.4330127239227295, 0.0), + (-2.9802322387695312e-08, 0.43600431084632874, 0.0), + (-0.2500000298023224, 0.43600431084632874, 0.0), +] +edge_idxs = [ + (12, 5), + (2, 3), + (1, 2), + (1, 11), + (5, 4), + (0, 6), + (3, 7), + (8, 9), + (1, 4), + (12, 1), + (10, 11), + (6, 5), + (11, 0), + (12, 0), + (4, 3), + (9, 10), + (7, 8), + (14, 13), + (13, 1), + (13, 10), + (9, 14), + (2, 14), + (8, 2), +] +face_idxs = [ + [6, 0, 12, 5], + [11, 1, 12, 0], + [3, 4, 1, 2], + [5, 12, 1, 4], + [11, 10, 13, 1], + [13, 10, 9, 14], + [1, 13, 14, 2], + [9, 8, 2, 14], + [7, 3, 2, 8], +] +split_vert_idxs = (7, 6, 11, 10, 9, 8) +additional_edge_flow_info = {"x": 22, "y": 9} +pattern_list.append( + Pattern( + name, + A, + b_r, + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + split_vert_idxs, + additional_edge_flow_info, + ) +) + +name = "6-3-i" +pattern_check = [1, 1, 1, 1, lambda i: (i[-1] - i[-2] - 2) / 2, lambda i: i[-2] - 2] +pattern_list.append( + Pattern( + name, + functions.flip_A(A), + b_r[::-1], + pattern_check, + vert_coords, + edge_idxs, + face_idxs, + (split_vert_idxs[0],) + split_vert_idxs[:0:-1], + additional_edge_flow_info, + ) +) diff --git a/preference.py b/preference.py new file mode 100644 index 0000000..fa1f72a --- /dev/null +++ b/preference.py @@ -0,0 +1,23 @@ +import bpy +from .utils.trans import t +from .utils import functions + + +class EasyPatchPrefs(bpy.types.AddonPreferences): + bl_idname = functions.get_package_name() # 設置爲package的名字否則不識別 + + desc_position_x: bpy.props.IntProperty(name=t("font_size"), default=5, min=0, max=100) # type: ignore + desc_position_y: bpy.props.IntProperty(name=t("font_size"), default=15, min=0, max=100) # type: ignore + + # TODO:经典模式开关,注意要在g建立一个变量确保一致性 + + def draw(self, context): + layout = self.layout + layout.label(text=t("hotkey_display_position")) + + row = layout.row() + row.prop(self, "desc_position_x", text="x", expand=True) + row.prop(self, "desc_position_y", text="y", expand=True) + + +classes = (EasyPatchPrefs,) diff --git a/property.py b/property.py new file mode 100644 index 0000000..b675e2e --- /dev/null +++ b/property.py @@ -0,0 +1,20 @@ +import bpy +from .utils.trans import t + + +def update_snap_obj(self, context): + if self.snap_obj == context.active_object: + self.snap_obj = None + + +class EasyPatchProperty(bpy.types.PropertyGroup): + snap_obj: bpy.props.PointerProperty( + type=bpy.types.Object, + description=t( + "snap_obj_setting_desc", + ), + update=update_snap_obj, + ) # type: ignore + + +property_classes = (EasyPatchProperty,) diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..1d4a0b0 --- /dev/null +++ b/ui.py @@ -0,0 +1,49 @@ +from . import const, g +import bpy +from . import op +from .utils.trans import t + + +class EasyPatchMainPanel(bpy.types.Panel): + bl_label = "Easy Patch" + bl_idname = "VIEW3D_PT_OIMOYU_EASY_Patch_MAIN_PANEL" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_context = "mesh_edit" + bl_options = {"DEFAULT_CLOSED"} + + bl_category = "Edit" + bl_order = 1 + + def draw(self, context): + layout = self.layout + + if g.is_running: + layout.row().operator( + op.EasyPatchModalOperator.bl_idname, + text=t("stop") + " Easy Patch", + icon="PAUSE", + ) + else: + layout.prop( + context.scene.easy_patch_properties, + "snap_obj", + text="", + ) + + layout.row().operator( + op.EasyPatchModalOperator.bl_idname, + text=t("start") + " Easy Patch", + icon="PLAY", + ) + + layout.separator() + + layout.row().operator( + op.EasyPatchSnapSelectedToSurfaceOperator.bl_idname, + text=t("snap_selected_to_surface"), + icon="MOD_SHRINKWRAP", + ) + + +classes = (EasyPatchMainPanel,) diff --git a/utils/functions.py b/utils/functions.py new file mode 100644 index 0000000..2178293 --- /dev/null +++ b/utils/functions.py @@ -0,0 +1,1145 @@ +import bmesh +from .. import g +import mathutils +from .. import const +import bpy +import heapq +import math +import numpy as np +from bpy_extras import view3d_utils +import random +from collections import defaultdict +from functools import wraps +import time + + +def safe_execute(func, *args, **kwargs): + try: + func(*args, **kwargs) + except Exception as e: + print(f"An exception occurred in {func.__name__}: {e}") + + +def get_snap_option_target_obj(): + snap_obj = bpy.context.scene.easy_patch_properties.snap_obj + if ( + snap_obj + and snap_obj.name in bpy.context.scene.objects + and snap_obj.type == "MESH" + ): + return snap_obj + else: + return None + + +def get_obj_bvh_tree(): + obj = g.obj + # 获取依赖图 + depsgraph = bpy.context.evaluated_depsgraph_get() + + evaluated_obj = obj.evaluated_get(depsgraph) + target_mesh = evaluated_obj.to_mesh() + + temp_bm = bmesh.new() + temp_bm.from_mesh(target_mesh) + temp_bm.transform(obj.matrix_world) # 应用对象的变换 + bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(temp_bm) + + temp_bm.free() + evaluated_obj.to_mesh_clear() + + return bvh_tree + + +# 获取bvh树,如果用户填了snap选项,则用snap,没填则用可见物体 +def get_snap_bvh_trees(exclude_object=[]): + snap_obj = get_snap_option_target_obj() + # 获取依赖图 + depsgraph = bpy.context.evaluated_depsgraph_get() + + # 构建BVH树,只针对非当前对象的其他网格对象 + bvh_trees = [] + if snap_obj: + # 获取评估后的对象数据,包含变换和修改器 + evaluated_obj = snap_obj.evaluated_get(depsgraph) + target_mesh = evaluated_obj.to_mesh() + + temp_bm = bmesh.new() + temp_bm.from_mesh(target_mesh) + temp_bm.transform(snap_obj.matrix_world) # 应用对象的变换 + + bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(temp_bm) + bvh_trees.append(bvh_tree) + temp_bm.free() + else: + # 获取所有可见的网格对象(排除当前对象) + visible_objects = [ + o + for o in bpy.context.visible_objects + if o.type == "MESH" + and o not in exclude_object + and o.visible_get() + and o != get_edit_obj() + ] + + # 构建场景中所有其他网格对象的BVH树,这里要排除不可见的物体、不在视图层的物体,否则会出现奇怪的捕捉,同时减轻构建BVH性能负担 + for scene_obj in visible_objects: + # 获取评估后的对象数据,包含变换和修改器 + evaluated_obj = scene_obj.evaluated_get(depsgraph) + target_mesh = evaluated_obj.to_mesh() + + temp_bm = bmesh.new() + temp_bm.from_mesh(target_mesh) + temp_bm.transform(scene_obj.matrix_world) # 应用对象的变换 + bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(temp_bm) + bvh_trees.append(bvh_tree) + + temp_bm.free() + evaluated_obj.to_mesh_clear() + + return bvh_trees + + +def get_bvh_tree_from_bm(bm: bmesh.types.BMesh): + # Prepare a list to store the bounding boxes (aabb) for each face + faces = [] + coords = [] + + # Loop through each face in the BMesh + for face in bm.faces: + # Calculate the bounding box for the current face + min_bound = mathutils.Vector((float("inf"), float("inf"), float("inf"))) + max_bound = mathutils.Vector((float("-inf"), float("-inf"), float("-inf"))) + + # Get the vertices of the face + for loop in face.loops: + vertex = loop.vert.co + min_bound = min(min_bound, vertex) + max_bound = max(max_bound, vertex) + + # Add the bounding box corners (min and max points) + faces.append((min_bound, max_bound)) + coords.append([v.co for v in face.verts]) + + # Create a BVHTree + bvh_tree = bpy.types.BVHTree() + + # Build the BVH tree from the faces (using the coords) + bvh_tree.build(coords) + + return bvh_tree + + +def log(input): + if bpy.app.debug: + print(f"Easy Patch: {input}") + + +def hsv_to_rgb(h, s, v, a=None): + if s == 0.0: + rgb = v, v, v + else: + i = int(h * 6.0) + f = h * 6.0 - i + p = v * (1.0 - s) + q = v * (1.0 - s * f) + t = v * (1.0 - s * (1.0 - f)) + i %= 6 + if i == 0: + rgb = v, t, p + if i == 1: + rgb = q, v, p + if i == 2: + rgb = p, v, t + if i == 3: + rgb = p, q, v + if i == 4: + rgb = t, p, v + if i == 5: + rgb = v, p, q + + return rgb if a is None else (*rgb, a) + + +# 只会删除边 +def remove_bm_edges(bm, bm_edges): + for bm_edge in bm_edges: + if bm_edge in bm.edges: + bm.edges.remove(bm_edge) + + +def delete_bm_verts(bm, bm_verts): + # # 先把记录的变量删除 + # for b in g.boundaries: + # for v in bm_verts: + # if v in b. + + temp_vertices = [v for v in bm_verts if v in bm.verts] + temp_vertices = list(set(temp_vertices)) + bmesh.ops.delete(bm, geom=temp_vertices, context="VERTS") + + +def remove_bm_faces(bm, faces): + for face in faces: + if face in bm.faces: + bm.faces.remove(face) + + +def uniform_vertices_co_along_path(verts, mid_vertex_num): + if len(verts) < 2 or mid_vertex_num < 0: + return ([], []) + + # total length + total_length = sum( + (mathutils.Vector(verts[i]) - mathutils.Vector(verts[i - 1])).length + for i in range(1, len(verts)) + ) + + # inverval distance of new vertex + new_verts_interval = total_length / (mid_vertex_num + 1) + + new_verts = [] + new_verts_correspond_path_idx = [] + path_verts_accumulated_length = 0.0 + segment_start = mathutils.Vector(verts[0]) + + for i in range(1, len(verts)): + segment_end = mathutils.Vector(verts[i]) + segment_length = (segment_end - segment_start).length + path_verts_accumulated_length += segment_length + + new_verts_accumulated_length = new_verts_interval * len(new_verts) + + if not segment_length > 0: + continue + + while ( + path_verts_accumulated_length > new_verts_accumulated_length + ): # if >= will include last. when mid_vertex_num too large >= will stuck in a number when increase, > will stuck in a number when decrease, + ratio = ( + path_verts_accumulated_length - new_verts_accumulated_length + ) / segment_length + + new_vert = segment_end.lerp(segment_start, ratio) + new_verts.append(new_vert) + new_verts_correspond_path_idx.append(i - 1) + new_verts_accumulated_length = new_verts_interval * len(new_verts) + + segment_start = segment_end + + # 未知原因导致多一个点,可能是最后一个点距离过近导致上述循环把最后一个点也包含进去了 + if len(new_verts) >= mid_vertex_num + 2: + new_verts[-1] = verts[-1] + new_verts_correspond_path_idx[-1] = len(verts) - 1 + else: + new_verts.append(verts[-1]) + new_verts_correspond_path_idx.append(len(verts) - 1) + return new_verts, new_verts_correspond_path_idx + + +def get_midpoint_of_path(bm, edge_path): + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + + if len(edge_path) == 0: + return + num_edges = len(edge_path) + + if num_edges % 2 == 0: + middle_index = num_edges // 2 + edge1 = edge_path[middle_index - 1] + edge2 = edge_path[middle_index] + + verts_edge1 = set(edge1.verts) + verts_edge2 = set(edge2.verts) + common_verts = verts_edge1.intersection(verts_edge2) + common_vert = next(iter(common_verts), None) + if common_vert: + return common_vert.co + else: + return mathutils.Vector((0, 0, 0)) + + # 如果边的数量为奇数,中点是中间边的中点 + else: + total_length = sum(edge.calc_length() for edge in edge_path) + half_length = total_length / 2 + accum_length = 0 + for edge in edge_path: + edge_length = edge.calc_length() + accum_length += edge_length + + if accum_length >= half_length: + overshoot = accum_length - half_length + factor = (edge_length - overshoot) / edge_length + v1, v2 = edge.verts + midpoint = v1.co.lerp(v2.co, factor) + return midpoint + + return mathutils.Vector((0, 0, 0)) # 如果路径为空,返回原点 + + +def get_edit_obj(): + return bpy.context.edit_object + + +def normal_local_to_world(obj, normal): + matrix = obj.matrix_world.to_3x3().inverted().transposed() + normal_world = matrix @ normal + if normal_world.length_squared == 0: + return mathutils.Vector((0.0, 0.0, 1.0)) + return normal_world.normalized() + + +def dijkstra( + bm, start, end, exclude_edges=[], exclude_verts=[], is_along_boundary=False +): + # 该函数计算的是累计路径长度,而不是累计拓扑路径长度 + + distances = { + v: float("inf") for v in bm.verts + } # 初始化距离字典,所有顶点的初始距离设为无穷大 + previous = {v: None for v in bm.verts} # 初始化前驱顶点字典,用于重建路径 + edge_to = {v: None for v in bm.verts} # 初始化边字典,用于存储到达每个顶点的边 + distances[start] = 0 # 起始顶点到自身的距离设为0 + queue = [] + heapq.heappush(queue, (0, 0, start)) # 元组包含距离、计数器和顶点 + + count = 1 # 计数器,用于解决堆中的比较冲突 + + while queue: + current_distance, _, current_vert = heapq.heappop( + queue + ) # 从队列中弹出当前距离最短的顶点 + if current_vert == end: # 如果到达终点,重建并返回路径 + path_verts = [] + path_edges = [] + while previous[current_vert]: + path_verts.insert(0, current_vert) + path_edges.insert(0, edge_to[current_vert]) + current_vert = previous[current_vert] + path_verts.insert(0, current_vert) + return path_verts, path_edges + + # 跳过处理距离更长的路径 + if current_distance > distances[current_vert]: + continue + + # 遍历当前顶点的所有相连边 + for edge in current_vert.link_edges: + if is_along_boundary and not edge.is_boundary: + continue + + if edge in exclude_edges: + continue + + neighbor = edge.other_vert(current_vert) # 获取邻接顶点 + # if neighbor in restricted_verts: + # continue # 跳过受限顶点 + if neighbor in exclude_verts and neighbor != end: + continue # 跳过受限顶点,除非它是终点 + + distance = current_distance + edge.calc_length() # 计算新的距离 + + # 更新邻接顶点的最短路径 + if distance < distances[neighbor]: + distances[neighbor] = distance + previous[neighbor] = current_vert + edge_to[neighbor] = edge + heapq.heappush(queue, (distance, count, neighbor)) + count += 1 + + return [], [] + + +# 更新drawing_path的路径,处理solid和非solid +def update_solid_path(context, event): + # if not g.drawing_path_boundary: + # return + + if not g.drawing_verts_snap_vertex or not event.shift: + g.drawing_path_boundary.solid_path = [] + g.drawing_path_boundary.edges = [] + g.drawing_path_boundary.verts = [] + return + + if (not event.type == "MOUSEMOVE") and ( + not (event.type == "LEFT_SHIFT" and event.value == "PRESS") + ): + return + + # update solid path + verts_path = [] + boundary = g.drawing_path_boundary + if boundary.start_vertex and boundary.start_vertex != g.drawing_verts_snap_vertex: + exclude_edges = g.temp_bm_edges + exclude_verts = g.temp_bm_verts + verts_path, edges_path = dijkstra( + g.obj_bm, + boundary.start_vertex, + g.drawing_verts_snap_vertex, + exclude_edges=exclude_edges, + exclude_verts=exclude_verts, + ) + + # 特殊情况:路径长度为1,且路径的结束点在temp中且不是patch结尾, + if len(verts_path) == 2 and verts_path[-1] in g.temp_bm_verts: + verts_path = [] + + if verts_path: + g.drawing_path_boundary.solid_path = [ + g.obj.matrix_world @ v.co for v in verts_path + ] + g.drawing_path_boundary.edges = edges_path + g.drawing_path_boundary.verts = verts_path + + +def update_snap_vertex(event, is_snap_opposite=False): + start_time = time.time() + if not event.ctrl: + g.drawing_verts_snap_vertex = None + return + + # # 注意使用,鼠标移动的事件是离散的,这个时间限制是连续,设置时间戳要放在最后,不然事件全部被阻塞了 + current_timestamp = time.time() + if current_timestamp - g.last_update_snap_timestamp < 1 / 10: + return + + if event.type != "MOUSEMOVE" and not ( + event.type == "LEFT_CTRL" and event.value == "PRESS" + ): + return + + if not bpy.context.tool_settings.mesh_select_mode[0]: + bpy.context.tool_settings.mesh_select_mode = (True, False, False) + + bpy.ops.mesh.select_all(action="DESELECT") + moust_co_2d = event.mouse_region_x, event.mouse_region_y + ret = bpy.ops.view3d.select(extend=True, location=moust_co_2d) + if "FINISHED" not in ret: + # 判断是否选中,即使判断也有可能获得None + g.drawing_verts_snap_vertex = None + return + + vertex = g.obj_bm.select_history[-1] + + # 上一个snap残留和新的不一样的话,直接用新的,如果一样,则判断阈值 + # 这里要加上None,不然的话一闪一闪 + if ( + vertex == g.drawing_verts_snap_vertex or g.drawing_verts_snap_vertex is None + ) and vertex: + # 做2d阈值 + context = bpy.context + region = context.region + rv3d = context.region_data + mouse_co_2d = mathutils.Vector((event.mouse_region_x, event.mouse_region_y)) + vertex_co_2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, g.obj.matrix_world @ vertex.co + ) + if not vertex_co_2d: + vertex = None + if (mouse_co_2d - vertex_co_2d).length > const.snap_vertex_threshold: + vertex = None + + # # 禁用2条边 + # if len(self.patch.boundaries) == 1 and vertex == self.patch.start_vertex: + # vertex = None + + # TODO:使用插件单独创建的时候,可能是反向的,导致吸附不上 + + # 处理法线方向 + if not is_snap_opposite and vertex and vertex.link_faces: + vert_normal_global = normal_local_to_world(g.obj, vertex.normal) + view_vector = bpy.context.region_data.view_rotation @ mathutils.Vector( + (0.0, 0.0, -1.0) + ) + # 法线和视图同向,不选择 + if vert_normal_global.dot(view_vector) > 0: + vertex = None + + g.drawing_verts_snap_vertex = vertex + + bpy.ops.mesh.select_all(action="DESELECT") + + g.last_update_snap_timestamp = current_timestamp # 这个要放到最后更新,不然中间有return了,就相当于没更新就设置了这个timestamp + + return + + +def create_temp_vertex(coord): + vertex = g.obj_bm.verts.new(g.obj.matrix_world.inverted() @ coord) + g.temp_bm_verts.add(vertex) + return vertex + + +def create_temp_edge(vertice_1, vertice_2): + edge = g.obj_bm.edges.new((vertice_1, vertice_2)) + g.temp_bm_edges.add(edge) + return edge + + +def get_nearest_boundary_from_mouse(event): + context = bpy.context + region = context.region + rv3d = context.region_data + mouse_co_2d = (event.mouse_region_x, event.mouse_region_y) + + threshold = const.hover_threshold + for boundary in g.boundaries: + + for edge in boundary.edges: + obj = g.obj + # vert.co获取的是局部坐标 + edge_coords_3d = [obj.matrix_world @ vert.co for vert in edge.verts] + edge_coords_2d = [ + view3d_utils.location_3d_to_region_2d(region, rv3d, coord) + for coord in edge_coords_3d + ] + if None in edge_coords_2d: + continue + + distance = distance_point_to_line( + mathutils.Vector(mouse_co_2d), + mathutils.Vector(edge_coords_2d[0]), + mathutils.Vector(edge_coords_2d[1]), + ) + + if distance < threshold: + return boundary + + +def distance_point_to_line(pt, v1, v2): + line_vec = v2 - v1 + + # in case 0 length + if line_vec.length == 0: + return (pt - v1).length + + pt_vec = pt - v1 + line_len = line_vec.length + line_unitvec = line_vec.normalized() + pt_vec_scaled = pt_vec * (1.0 / line_len) + t = line_unitvec.dot(pt_vec_scaled) + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + nearest = v1 + line_vec * t + dist = (nearest - pt).length + return dist + + +def stable_random_from_obj(obj): + obj_id = id(obj) + random.seed(obj_id) + stable_random_value = random.random() + return stable_random_value + + +def get_loops_from_boundary(boundary): + loops = [] + for loop in g.loops: + if boundary in loop.boundaries: + loops.append(loop) + return loops + + +def on_seperate_boundary_(boundary): + # 已在 done_from_temp写了 + pass + + +def on_change_boundary_length(boundary): + # boundary长度改变,loop不变,pattern形状会变, + loops = get_loops_from_boundary(boundary) + for loop in loops: + loop.recreate_pattern() + + g.is_redraw_boundary = True + g.is_redraw_pattern = True + + +def on_delete_boundary(): + # boundary删除,loop可能会被删除,loop被删pattern也会被删 + pass + + +def on_create_boundary(): + # boundary创建,loop可能增加,pattern可能增加 + pass + + +def on_change_patch_pattern(): + pass + + +def rotate_list(lst, n): + if not lst: + return [] + n = n % len(lst) + return lst[n:] + lst[:n] + + +def edge_loops(bm, edge): + # 获取循环边 + def walk(edge): + yield edge + edge.tag = True + for l in edge.link_loops: + loop = l.link_loop_radial_next.link_loop_next.link_loop_next + if not (len(loop.face.verts) != 4 or loop.edge.tag): + yield from walk(loop.edge) + + for e in bm.edges: + e.tag = False + return list(walk(edge)) + + +def get_verts_and_edges_from_faces(bm, faces): + faces = [i for i in faces if i in bm.faces] + verts = set() + edges = set() + for face in faces: + for edge in face.edges: + edges.add(edge) + for vert in face.verts: + verts.add(vert) + + return list(verts), list(edges) + + +def smooth_verts_by_normal(obj, bm, verts, smooth_type=0): + # https://github.com/fedackb/mesh-fairing + + # ##### BEGIN GPL LICENSE BLOCK ##### + # + # This program is free software: you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by + # the Free Software Foundation, either version 3 of the License, or + # (at your option) any later version. + # + # This program is distributed in the hope that it will be useful, + # but WITHOUT ANY WARRANTY; without even the implied warranty of + # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + # GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . + # + # ##### END GPL LICENSE BLOCK ##### + def setup_fairing( + v: bmesh.types.BMVert, + i, + A, + b, + multiplier, + depth, + vert_col_map, + vert_weights, + loop_weights, + ): + if depth == 0: + if v in vert_col_map: + j = vert_col_map[v] + if (i, j) not in A: + A[i, j] = 0 + A[i, j] -= multiplier + else: + b[i][0] += multiplier * v.co.x + b[i][1] += multiplier * v.co.y + b[i][2] += multiplier * v.co.z + else: + w_ij_sum = 0 + w_i = vert_weights(v) + for l in v.link_loops: + other = l.link_loop_next.vert + w_ij = loop_weights(l) + w_ij_sum += w_ij + setup_fairing( + other, + i, + A, + b, + w_i * w_ij * multiplier, + depth - 1, + vert_col_map, + vert_weights, + loop_weights, + ) + setup_fairing( + v, + i, + A, + b, + -1 * w_i * w_ij_sum * multiplier, + depth - 1, + vert_col_map, + vert_weights, + loop_weights, + ) + + def calc_circumcenter(a, b, c): + ab = b - a + ac = c - a + ab_cross_ac = ab.cross(ac) + if ab_cross_ac.length_squared > 0: + d = ac.length_squared * ab_cross_ac.cross(ab) + d += ab.length_squared * ac.cross(ab_cross_ac) + d /= 2 * ab_cross_ac.length_squared + return a + d + else: + return a + + def calc_uniform_vertex_weight(v): + n = len(v.link_edges) + return 1 / n if n != 0 else float("inf") + + def calc_uniform_loop_weight(l): + return 1 + + def calc_voronoi_vertex_weight(v): + area = 0 + a = v.co + acute_threshold = math.pi / 2 + for l in v.link_loops: + b = l.link_loop_next.vert.co + c = l.link_loop_prev.vert.co + if l.calc_angle() < acute_threshold: + d = calc_circumcenter(a, b, c) + else: + d = (b + c) / 2 + area += mathutils.geometry.area_tri(a, (a + b) / 2, d) + area += mathutils.geometry.area_tri(a, d, (a + c) / 2) + return 1 / area if area != 0 else 1e12 + + def calc_cotangent_loop_weight(l): + weight = 0 + co_a = l.vert.co + co_b = l.link_loop_next.vert.co + coords = [l.link_loop_prev.vert.co] + if not l.edge.is_boundary: + coords.append(l.link_loop_radial_next.link_loop_next.link_loop_next.vert.co) + for co_c in coords: + try: + angle = (co_a - co_c).angle(co_b - co_c) + weight += 1 / math.tan(angle) + except (ValueError, ZeroDivisionError): + weight += 1e-4 + weight /= 2 + return weight + + def _do(verts, order, vert_weights, loop_weights): + interior_verts = (v for v in verts if not v.is_boundary and not v.is_wire) + vert_col_map = {v: col for col, v in enumerate(interior_verts)} + A = dict() + b = [[0 for i in range(3)] for j in range(len(vert_col_map))] + for v, col in vert_col_map.items(): + setup_fairing( + v, col, A, b, 1, order, vert_col_map, vert_weights, loop_weights + ) + + x = None + n = len(b) + A_numpy = np.zeros((n, n), dtype="d") + b = np.asarray(b, dtype="d") + for key, val in A.items(): + A_numpy[key] = val + x = np.linalg.solve(A_numpy, b) + + for v, co in zip(verts, x): + v.co = co + + verts = [v for v in verts if v in bm.verts] + if smooth_type == 0: + _do(verts, 1, calc_uniform_vertex_weight, calc_uniform_loop_weight) + _do(verts, 1, calc_voronoi_vertex_weight, calc_cotangent_loop_weight) + else: + _do(verts, 1, calc_uniform_vertex_weight, calc_uniform_loop_weight) + _do(verts, 2, calc_voronoi_vertex_weight, calc_cotangent_loop_weight) + + bm.normal_update() + bmesh.update_edit_mesh(obj.data) + + +# TODO: 位于边缘的顶点法线方向不准,看看有没有现成api,没有就采样旁边的面 +def snap_verts_to_surface_along_normal( + obj, bm, verts, bvh_trees, allow_opposite_direction=False +): + + snap_count = 0 + # 遍历所有选中的顶点 + for v in verts: + # 获取顶点的全局坐标和法向 + global_co = obj.matrix_world @ v.co + + global_normal = normal_local_to_world(obj, v.normal) + + # if v.is_boundary: + # # 如果是边界顶点,计算链接面的平均法向量 + # face_normals = [] + # for face in v.link_faces: + # face_normals.append(face.normal) + # average_normal = ( + # sum(face_normals, mathutils.Vector()) / len(face_normals) + # if face_normals + # else mathutils.Vector((0, 0, 0)) + # ) + # global_normal = (obj.matrix_world.to_3x3() @ average_normal).normalized() + # else: + # # 否则,直接使用顶点的法向量 + # global_normal = (obj.matrix_world.to_3x3() @ v.normal).normalized() + + closest_location = None + closest_distance = float("inf") + closest_location_opposite = None + closest_distance_opposite = float("inf") + + # 遍历每个 BVH 树,优先正方向投射,避免吸附到物体另一侧造成拉伸 + for bvh_tree in bvh_trees: + # 正方向射线投射 + result_pos = bvh_tree.ray_cast(global_co, global_normal) + if result_pos[0]: + location_pos, _, _, distance_pos = result_pos + if distance_pos < closest_distance: + closest_location = location_pos + closest_distance = distance_pos + + if allow_opposite_direction: + result_neg = bvh_tree.ray_cast(global_co, -global_normal) + if result_neg[0]: + location_neg, _, _, distance_neg = result_neg + if distance_neg < closest_distance_opposite: + closest_location_opposite = location_neg + closest_distance_opposite = distance_neg + + # 如果找到最近的投影点,则将顶点移动到该点 + if not closest_location and closest_location_opposite: + closest_location = closest_location_opposite + + if closest_location: + # 将全局坐标转换回局部坐标 + new_local_co = obj.matrix_world.inverted() @ closest_location + v.co = new_local_co + snap_count += 1 + + # 将顶点更改应用到网格上,loop_triangles提高效率 + bm.normal_update() + bmesh.update_edit_mesh(obj.data, loop_triangles=True) + + +# 666case will be failed +def get_all_reduced_inputs(loop_shape, is_remove_duplicated_rotation=True): + def reduce_and_explore(current_lst, results): + reduced = False + for k in range(len(current_lst)): + prev_index = (k - 1) % len(current_lst) + next_index = (k + 1) % len(current_lst) + if current_lst[prev_index] > 1 and current_lst[next_index] > 1: + new_lst = current_lst.copy() + reduction = min(new_lst[prev_index], new_lst[next_index]) - 1 + new_lst[prev_index] -= reduction + new_lst[next_index] -= reduction + reduce_and_explore(new_lst, results) + reduced = True + + if not reduced: + results.add(tuple(current_lst)) + + def is_rotate_dup(tup1, tup2): + if len(tup1) != len(tup2): + return False + return any(tup1 == tup2[i:] + tup2[:i] for i in range(len(tup2))) + + def max_consecutive_ones(tup): + count = 0 + max_count = 0 + for num in tup: + if num == 1: + count += 1 + max_count = max(max_count, count) + else: + count = 0 + return max_count + + # 两条边的时候特殊处理 + if len(loop_shape) == 2: + if loop_shape[0] == loop_shape[1]: + return [(2, 2)] + else: + if loop_shape[0] > loop_shape[1]: + return [(3, 1)] + else: + return [(1, 3)] + + all_results = set() + reduce_and_explore(list(loop_shape), all_results) + + if not is_remove_duplicated_rotation: + return all_results + + # 删除重复 + unique_results = [] + for tup in list(all_results): + if not any(is_rotate_dup(tup, existing) for existing in unique_results): + unique_results.append(tup) + + # # 排序,1越少越往后,防止缩减错误和复杂度 + # sorted_results = sorted(unique_results, key=lambda x: x.count(1), reverse=True) + + sorted_results = sorted( + unique_results, + key=lambda x: (max_consecutive_ones(x), x.count(1)), + reverse=True, + ) + + return sorted_results + + +def flip_A(A_): + row_num = len(A_) + A = [] + for row_idx, row in enumerate(A_): + flip_row_idx = row_num - row_idx - 1 + row = row[:row_num] + A_[flip_row_idx][row_num:] + A.append(row) + return A + + +# def process_path_to_mirror_axis(path): +# # 首先,x坐标全部归零 +# for co in path: +# co.x = 0 + +# # 然后,投射到表面(如果用户操作不是太离谱,这一步不用) +# # 首先投射方向是在yz平面上的(即垂直于x轴),然后方向垂直于连线,有两个方向,都raycast,然后投射到距离最近的那个 + +# for i, co in enumerate(path): +# # 获取相邻的路径点 +# if i == len(path) - 1: +# neighbor_co = path[-2] +# else: +# neighbor_co = path[i + 1] + +# line_vector = neighbor_co - co +# x_normal = mathutils.Vector((1, 0, 0)) + +# cross_product = line_vector.cross( +# x_normal +# ) # 两向量叉乘,得到垂直于两个向量的向量 +# raycast_direction = cross_product.normalized() + +# # 获取顶点的全局坐标 +# global_co = co + +# closest_location = None +# closest_distance = float("inf") + +# # 遍历每个 BVH 树,进行双向射线投射 +# for bvh_tree in g.snap_bvh_trees: +# # 正方向射线投射 +# result_pos = bvh_tree.ray_cast(global_co, raycast_direction) +# if result_pos[0]: +# location_pos, _, _, distance_pos = result_pos +# if distance_pos < closest_distance: +# closest_location = location_pos +# closest_distance = distance_pos + +# # 负方向射线投射 +# result_neg = bvh_tree.ray_cast(global_co, -raycast_direction) +# if result_neg[0]: +# location_neg, _, _, distance_neg = result_neg +# if distance_neg < closest_distance: +# closest_location = location_neg +# closest_distance = distance_neg + +# # 如果找到最近的投影点,则将坐标改为该投射点 +# if closest_location: +# closest_location.x = 0 +# path[i] = closest_location + + +def project_point_to_plane(point_coord, plane_normal, plane_coord): + # 确保法向量是单位向量 + plane_normal.normalize() + + # 计算点到平面的向量 + vector_to_plane = point_coord - plane_coord + + # 计算点到平面的垂直距离 + distance = vector_to_plane.dot(plane_normal) + + # 计算投影点 + projected_point = point_coord - distance * plane_normal + + return projected_point + + +def process_path_to_mirror_axis(path): + obj_x_vector = g.obj.matrix_world.col[ + 0 + ].xyz # 获取 X 正方向的向量(第1列),只取前三个分量 (X, Y, Z) + obj_coord = g.obj.location + + # 先投影到轴上 + for i in range(len(path)): + point_coord = path[i] + path[i] = project_point_to_plane(point_coord, obj_x_vector, obj_coord) + + # 然后,投射到表面(如果用户操作不是太离谱,这一步不用) + # 首先投射方向是在yz平面上的(即垂直于x轴),然后方向垂直于连线,有两个方向,都raycast,然后投射到距离最近的那个 + + for i, co in enumerate(path): + # 获取相邻的路径点 + if i == len(path) - 1: + neighbor_co = path[-2] + else: + neighbor_co = path[i + 1] + + line_vector = neighbor_co - co + x_normal = obj_x_vector + + cross_product = line_vector.cross( + x_normal + ) # 两向量叉乘,得到垂直于两个向量的向量 + raycast_direction = cross_product.normalized() + + # 获取顶点的全局坐标,这个就是全局,别转了 + global_co = co + + closest_location = None + closest_distance = float("inf") + + # 遍历每个 BVH 树,进行双向射线投射 + for bvh_tree in g.snap_bvh_trees: + # 正方向射线投射 + result_pos = bvh_tree.ray_cast(global_co, raycast_direction) + if result_pos[0]: + location_pos, _, _, distance_pos = result_pos + if distance_pos < closest_distance: + closest_location = location_pos + closest_distance = distance_pos + + # 负方向射线投射 + result_neg = bvh_tree.ray_cast(global_co, -raycast_direction) + if result_neg[0]: + location_neg, _, _, distance_neg = result_neg + if distance_neg < closest_distance: + closest_location = location_neg + closest_distance = distance_neg + + # 如果找到最近的投影点,则将坐标改为该投射点,这里再投影一次,不过理论上来说没有必要 + if closest_location: + path[i] = project_point_to_plane(closest_location, obj_x_vector, obj_coord) + + +def coords_3d_to_2d(region, rv3d, coords_3d): + coords_2d = [] + for coord in coords_3d: + result = view3d_utils.location_3d_to_region_2d(region, rv3d, coord) + if result is not None: + coords_2d.append(result) + return coords_2d + + +def set_block_before_done(func): + @wraps(func) + def wrapper(*args, **kwargs): + # 函数开始前设置为 + g.is_changing_mesh = True + try: + # 执行函数 + return func(*args, **kwargs) + finally: + # 函数结束后设置为 + g.is_changing_mesh = False + g.hovering_loop = None + g.hovering_boundary = None + + return wrapper + + +def smooth_verts_by_avg(obj, bm, verts): + verts = [v for v in verts if v in bm.verts] + + iterations = 5 + for _ in range(iterations): + new_positions = [] + max_movement = 0 + + for v in verts: + avg = mathutils.Vector((0, 0, 0)) + for e in v.link_edges: + avg += e.other_vert(v).co + avg /= len(v.link_edges) + + # 计算移动量 + movement = (v.co - avg).length + max_movement = max(max_movement, movement) + new_positions.append((v, avg)) + + for v, new_co in new_positions: + v.co = new_co + + bm.normal_update() + bmesh.update_edit_mesh(obj.data) + + +def flip_normal(obj, bm, faces): + faces = [i for i in faces if i in bm.faces] + for face in faces: + face.normal_flip() + bm.normal_update() + bmesh.update_edit_mesh(obj.data) + + +# 法向全部朝视图 +def faces_normal_toward_viewport(obj, bm, faces): + # 确保faces只包含有效的面 + faces = [i for i in faces if i in bm.faces] + + region = bpy.context.region + view_direction = view3d_utils.region_2d_to_vector_3d( + region, region.data, (region.width / 2.0, region.height / 2.0) + ) + # 变换物体空间的法向量到世界空间 + for face in faces: + normal_world = normal_local_to_world(obj, face.normal) + if normal_world.dot(view_direction) > 0: # 和视图同向,反转 + face.normal_flip() + bm.normal_update() + bmesh.update_edit_mesh(obj.data) + + +def get_3d_area(): + try: + for a in bpy.data.window_managers[0].windows[0].screen.areas: + if a.type == "VIEW_3D": + return a + return None + except: + return None + + +def get_created_verts_num(): + g.temp_bm_verts = [v for v in g.temp_bm_verts if v in g.obj_bm.verts] + return len(g.temp_bm_verts) + + +def get_package_name(): + return __name__.split(".")[0] + + +def get_preferences(): + return bpy.context.preferences.addons[get_package_name()].preferences + + +def is_mouse_move(event): + if not g.last_mouse_co_2d: + return True + + if ( + g.last_mouse_co_2d + - mathutils.Vector((event.mouse_region_x, event.mouse_region_y)) + ).length < const.mouse_move_threshold: + return False + else: + return True diff --git a/utils/pulp/__init__.py b/utils/pulp/__init__.py new file mode 100644 index 0000000..627fbb2 --- /dev/null +++ b/utils/pulp/__init__.py @@ -0,0 +1,41 @@ +# PuLP : Python LP Modeler +# Version 1.20 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id: __init__.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +Module file that imports all of the pulp functions + +Copyright 2007- Stuart Mitchell (s.mitchell@auckland.ac.nz) +""" +from .constants import VERSION + +from .pulp import * +from .apis import * +from .utilities import * +from .constants import * +from .tests import pulpTestAll + +__doc__ = pulp.__doc__ +__version__ = VERSION diff --git a/utils/pulp/apis/__init__.py b/utils/pulp/apis/__init__.py new file mode 100644 index 0000000..df70752 --- /dev/null +++ b/utils/pulp/apis/__init__.py @@ -0,0 +1,165 @@ +from .coin_api import * +from .cplex_api import * +from .gurobi_api import * +from .glpk_api import * +from .choco_api import * +from .mipcl_api import * +from .mosek_api import * +from .scip_api import * +from .xpress_api import * +from .highs_api import * +from .copt_api import * +from .core import * + +_all_solvers = [ + GLPK_CMD, + PYGLPK, + CPLEX_CMD, + CPLEX_PY, + GUROBI, + GUROBI_CMD, + MOSEK, + XPRESS, + XPRESS_CMD, + XPRESS_PY, + PULP_CBC_CMD, + COIN_CMD, + COINMP_DLL, + CHOCO_CMD, + MIPCL_CMD, + SCIP_CMD, + FSCIP_CMD, + SCIP_PY, + HiGHS, + HiGHS_CMD, + COPT, + COPT_DLL, + COPT_CMD, +] + +import json + +# Default solver selection +if PULP_CBC_CMD().available(): + LpSolverDefault = PULP_CBC_CMD() +elif GLPK_CMD().available(): + LpSolverDefault = GLPK_CMD() +elif COIN_CMD().available(): + LpSolverDefault = COIN_CMD() +else: + LpSolverDefault = None + + +def setConfigInformation(**keywords): + """ + set the data in the configuration file + at the moment will only edit things in [locations] + the keyword value pairs come from the keywords dictionary + """ + # TODO: extend if we ever add another section in the config file + # read the old configuration + config = Parser() + config.read(config_filename) + # set the new keys + for key, val in keywords.items(): + config.set("locations", key, val) + # write the new configuration + fp = open(config_filename, "w") + config.write(fp) + fp.close() + + +def configSolvers(): + """ + Configure the path the the solvers on the command line + + Designed to configure the file locations of the solvers from the + command line after installation + """ + configlist = [ + (cplex_dll_path, "cplexpath", "CPLEX: "), + (coinMP_path, "coinmppath", "CoinMP dll (windows only): "), + ] + print( + "Please type the full path including filename and extension \n" + + "for each solver available" + ) + configdict = {} + for default, key, msg in configlist: + value = input(msg + "[" + str(default) + "]") + if value: + configdict[key] = value + setConfigInformation(**configdict) + + +def getSolver(solver, *args, **kwargs): + """ + Instantiates a solver from its name + + :param str solver: solver name to create + :param args: additional arguments to the solver + :param kwargs: additional keyword arguments to the solver + :return: solver of type :py:class:`LpSolver` + """ + mapping = {k.name: k for k in _all_solvers} + try: + return mapping[solver](*args, **kwargs) + except KeyError: + raise PulpSolverError( + "The solver {} does not exist in PuLP.\nPossible options are: \n{}".format( + solver, mapping.keys() + ) + ) + + +def getSolverFromDict(data): + """ + Instantiates a solver from a dictionary with its data + + :param dict data: a dictionary with, at least an "solver" key with the name + of the solver to create + :return: a solver of type :py:class:`LpSolver` + :raises PulpSolverError: if the dictionary does not have the "solver" key + :rtype: LpSolver + """ + solver = data.pop("solver", None) + if solver is None: + raise PulpSolverError("The json file has no solver attribute.") + return getSolver(solver, **data) + + +def getSolverFromJson(filename): + """ + Instantiates a solver from a json file with its data + + :param str filename: name of the json file to read + :return: a solver of type :py:class:`LpSolver` + :rtype: LpSolver + """ + with open(filename) as f: + data = json.load(f) + return getSolverFromDict(data) + + +def listSolvers(onlyAvailable=False): + """ + List the names of all the existing solvers in PuLP + + :param bool onlyAvailable: if True, only show the available solvers + :return: list of solver names + :rtype: list + """ + result = [] + for s in _all_solvers: + solver = s() + if (not onlyAvailable) or solver.available(): + result.append(solver.name) + del solver + return result + + +# DEPRECATED aliases: +get_solver = getSolver +get_solver_from_json = getSolverFromJson +get_solver_from_dict = getSolverFromDict +list_solvers = listSolvers diff --git a/utils/pulp/apis/choco_api.py b/utils/pulp/apis/choco_api.py new file mode 100644 index 0000000..256c2ec --- /dev/null +++ b/utils/pulp/apis/choco_api.py @@ -0,0 +1,158 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +from .core import LpSolver_CMD, subprocess, PulpSolverError +import os +from .. import constants +import warnings + + +class CHOCO_CMD(LpSolver_CMD): + """The CHOCO_CMD solver""" + + name = "CHOCO_CMD" + + def __init__( + self, + path=None, + keepFiles=False, + mip=True, + msg=True, + options=None, + timeLimit=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param list options: list of additional options to pass to solver + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + """ + LpSolver_CMD.__init__( + self, + mip=mip, + msg=msg, + timeLimit=timeLimit, + options=options, + path=path, + keepFiles=keepFiles, + ) + + def defaultPath(self): + return self.executableExtension("choco-parsers-with-dependencies.jar") + + def available(self): + """True if the solver is available""" + java_path = self.executableExtension("java") + return self.executable(self.path) and self.executable(java_path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + java_path = self.executableExtension("java") + if not self.executable(java_path): + raise PulpSolverError( + "PuLP: java needs to be installed and accesible in order to use CHOCO_CMD" + ) + if not os.path.exists(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + tmpMps, tmpLp, tmpSol = self.create_tmp_files(lp.name, "mps", "lp", "sol") + # just to report duplicated variables: + lp.checkDuplicateVars() + + lp.writeMPS(tmpMps, mpsSense=lp.sense) + try: + os.remove(tmpSol) + except: + pass + cmd = java_path + ' -cp "' + self.path + '" org.chocosolver.parser.mps.ChocoMPS' + if self.timeLimit is not None: + cmd += f" -tl {self.timeLimit}" * 1000 + cmd += " " + " ".join([f"{key} {value}" for key, value in self.options]) + cmd += f" {tmpMps}" + if lp.sense == constants.LpMaximize: + cmd += " -max" + if lp.isMIP(): + if not self.mip: + warnings.warn("CHOCO_CMD cannot solve the relaxation of a problem") + # we always get the output to a file. + # if not, we cannot read it afterwards + # (we thus ignore the self.msg parameter) + pipe = open(tmpSol, "w") + + return_code = subprocess.call(cmd, stdout=pipe, stderr=pipe, shell=True) + + if return_code != 0: + raise PulpSolverError("PuLP: Error while trying to execute " + self.path) + if not os.path.exists(tmpSol): + status = constants.LpStatusNotSolved + status_sol = constants.LpSolutionNoSolutionFound + values = None + else: + status, values, status_sol = self.readsol(tmpSol) + self.delete_tmp_files(tmpMps, tmpLp, tmpSol) + + lp.assignStatus(status, status_sol) + if status not in [constants.LpStatusInfeasible, constants.LpStatusNotSolved]: + lp.assignVarsVals(values) + + return status + + @staticmethod + def readsol(filename): + """Read a Choco solution file""" + # TODO: figure out the unbounded status in choco solver + chocoStatus = { + "OPTIMUM FOUND": constants.LpStatusOptimal, + "SATISFIABLE": constants.LpStatusOptimal, + "UNSATISFIABLE": constants.LpStatusInfeasible, + "UNKNOWN": constants.LpStatusNotSolved, + } + + chocoSolStatus = { + "OPTIMUM FOUND": constants.LpSolutionOptimal, + "SATISFIABLE": constants.LpSolutionIntegerFeasible, + "UNSATISFIABLE": constants.LpSolutionInfeasible, + "UNKNOWN": constants.LpSolutionNoSolutionFound, + } + + status = constants.LpStatusNotSolved + sol_status = constants.LpSolutionNoSolutionFound + values = {} + with open(filename) as f: + content = f.readlines() + content = [l.strip() for l in content if l[:2] not in ["o ", "c "]] + if not len(content): + return status, values, sol_status + if content[0][:2] == "s ": + status_str = content[0][2:] + status = chocoStatus[status_str] + sol_status = chocoSolStatus[status_str] + for line in content[1:]: + name, value = line.split() + values[name] = float(value) + + return status, values, sol_status diff --git a/utils/pulp/apis/coin_api.py b/utils/pulp/apis/coin_api.py new file mode 100644 index 0000000..257b1eb --- /dev/null +++ b/utils/pulp/apis/coin_api.py @@ -0,0 +1,873 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +from .core import LpSolver_CMD, LpSolver, subprocess, PulpSolverError, clock, log +from .core import cbc_path, pulp_cbc_path, coinMP_path, devnull, operating_system +import os +from .. import constants +from tempfile import mktemp +import ctypes +import warnings + + +class COIN_CMD(LpSolver_CMD): + """The COIN CLP/CBC LP solver + now only uses cbc + """ + + name = "COIN_CMD" + + def defaultPath(self): + return self.executableExtension(cbc_path) + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + fracGap=None, + maxSeconds=None, + gapRel=None, + gapAbs=None, + presolve=None, + cuts=None, + strong=None, + options=None, + warmStart=False, + keepFiles=False, + path=None, + threads=None, + logPath=None, + timeMode="elapsed", + mip_start=False, + maxNodes=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param int threads: sets the maximum number of threads + :param list options: list of additional options to pass to solver + :param bool warmStart: if True, the solver will use the current value of variables as a start + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + :param str logPath: path to the log file + :param bool presolve: if True, adds presolve on + :param bool cuts: if True, adds gomory on knapsack on probing on + :param bool strong: if True, adds strong + :param float fracGap: deprecated for gapRel + :param float maxSeconds: deprecated for timeLimit + :param str timeMode: "elapsed": count wall-time to timeLimit; "cpu": count cpu-time + :param bool mip_start: deprecated for warmStart + :param int maxNodes: max number of nodes during branching. Stops the solving when reached. + """ + + if fracGap is not None: + warnings.warn("Parameter fracGap is being depreciated for gapRel") + if gapRel is not None: + warnings.warn("Parameter gapRel and fracGap passed, using gapRel") + else: + gapRel = fracGap + if maxSeconds is not None: + warnings.warn("Parameter maxSeconds is being depreciated for timeLimit") + if timeLimit is not None: + warnings.warn( + "Parameter timeLimit and maxSeconds passed, using timeLimit" + ) + else: + timeLimit = maxSeconds + if mip_start: + warnings.warn("Parameter mip_start is being depreciated for warmStart") + if warmStart: + warnings.warn( + "Parameter mipStart and mip_start passed, using warmStart" + ) + else: + warmStart = mip_start + LpSolver_CMD.__init__( + self, + gapRel=gapRel, + mip=mip, + msg=msg, + timeLimit=timeLimit, + presolve=presolve, + cuts=cuts, + strong=strong, + options=options, + warmStart=warmStart, + path=path, + keepFiles=keepFiles, + threads=threads, + gapAbs=gapAbs, + logPath=logPath, + timeMode=timeMode, + maxNodes=maxNodes, + ) + + def copy(self): + """Make a copy of self""" + aCopy = LpSolver_CMD.copy(self) + aCopy.optionsDict = self.optionsDict + return aCopy + + def actualSolve(self, lp, **kwargs): + """Solve a well formulated lp problem""" + return self.solve_CBC(lp, **kwargs) + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def solve_CBC(self, lp, use_mps=True): + """Solve a MIP problem using CBC""" + if not self.executable(self.path): + raise PulpSolverError( + f"Pulp: cannot execute {self.path} cwd: {os.getcwd()}" + ) + tmpLp, tmpMps, tmpSol, tmpMst = self.create_tmp_files( + lp.name, "lp", "mps", "sol", "mst" + ) + if use_mps: + vs, variablesNames, constraintsNames, objectiveName = lp.writeMPS( + tmpMps, rename=1 + ) + cmds = " " + tmpMps + " " + if lp.sense == constants.LpMaximize: + cmds += "-max " + else: + vs = lp.writeLP(tmpLp) + # In the Lp we do not create new variable or constraint names: + variablesNames = {v.name: v.name for v in vs} + constraintsNames = {c: c for c in lp.constraints} + cmds = " " + tmpLp + " " + if self.optionsDict.get("warmStart", False): + self.writesol(tmpMst, lp, vs, variablesNames, constraintsNames) + cmds += f"-mips {tmpMst} " + if self.timeLimit is not None: + cmds += f"-sec {self.timeLimit} " + options = self.options + self.getOptions() + for option in options: + cmds += "-" + option + " " + if self.mip: + cmds += "-branch " + else: + cmds += "-initialSolve " + cmds += "-printingOptions all " + cmds += "-solution " + tmpSol + " " + if self.msg: + pipe = None + else: + pipe = open(os.devnull, "w") + logPath = self.optionsDict.get("logPath") + if logPath: + if self.msg: + warnings.warn( + "`logPath` argument replaces `msg=1`. The output will be redirected to the log file." + ) + pipe = open(self.optionsDict["logPath"], "w") + log.debug(self.path + cmds) + args = [] + args.append(self.path) + args.extend(cmds[1:].split()) + if not self.msg and operating_system == "win": + # Prevent flashing windows if used from a GUI application + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + cbc = subprocess.Popen( + args, stdout=pipe, stderr=pipe, stdin=devnull, startupinfo=startupinfo + ) + else: + cbc = subprocess.Popen(args, stdout=pipe, stderr=pipe, stdin=devnull) + if cbc.wait() != 0: + if pipe: + pipe.close() + raise PulpSolverError( + "Pulp: Error while trying to execute, use msg=True for more details" + + self.path + ) + if pipe: + pipe.close() + if not os.path.exists(tmpSol): + raise PulpSolverError("Pulp: Error while executing " + self.path) + ( + status, + values, + reducedCosts, + shadowPrices, + slacks, + sol_status, + ) = self.readsol_MPS(tmpSol, lp, vs, variablesNames, constraintsNames) + lp.assignVarsVals(values) + lp.assignVarsDj(reducedCosts) + lp.assignConsPi(shadowPrices) + lp.assignConsSlack(slacks, activity=True) + lp.assignStatus(status, sol_status) + self.delete_tmp_files(tmpMps, tmpLp, tmpSol, tmpMst) + return status + + def getOptions(self): + params_eq = dict( + gapRel="ratio {}", + gapAbs="allow {}", + threads="threads {}", + presolve="presolve on", + strong="strong {}", + cuts="gomory on knapsack on probing on", + timeMode="timeMode {}", + maxNodes="maxNodes {}", + ) + + return [ + v.format(self.optionsDict[k]) + for k, v in params_eq.items() + if self.optionsDict.get(k) is not None + ] + + def readsol_MPS( + self, filename, lp, vs, variablesNames, constraintsNames, objectiveName=None + ): + """ + Read a CBC solution file generated from an mps or lp file (possible different names) + """ + values = {v.name: 0 for v in vs} + + reverseVn = {v: k for k, v in variablesNames.items()} + reverseCn = {v: k for k, v in constraintsNames.items()} + + reducedCosts = {} + shadowPrices = {} + slacks = {} + status, sol_status = self.get_status(filename) + with open(filename) as f: + for l in f: + if len(l) <= 2: + break + l = l.split() + # incase the solution is infeasible + if l[0] == "**": + l = l[1:] + vn = l[1] + val = l[2] + dj = l[3] + if vn in reverseVn: + values[reverseVn[vn]] = float(val) + reducedCosts[reverseVn[vn]] = float(dj) + if vn in reverseCn: + slacks[reverseCn[vn]] = float(val) + shadowPrices[reverseCn[vn]] = float(dj) + return status, values, reducedCosts, shadowPrices, slacks, sol_status + + def writesol(self, filename, lp, vs, variablesNames, constraintsNames): + """ + Writes a CBC solution file generated from an mps / lp file (possible different names) + returns True on success + """ + values = {v.name: v.value() if v.value() is not None else 0 for v in vs} + value_lines = [] + value_lines += [ + (i, v, values[k], 0) for i, (k, v) in enumerate(variablesNames.items()) + ] + lines = ["Stopped on time - objective value 0\n"] + lines += ["{:>7} {} {:>15} {:>23}\n".format(*tup) for tup in value_lines] + + with open(filename, "w") as f: + f.writelines(lines) + + return True + + def readsol_LP(self, filename, lp, vs): + """ + Read a CBC solution file generated from an lp (good names) + returns status, values, reducedCosts, shadowPrices, slacks, sol_status + """ + variablesNames = {v.name: v.name for v in vs} + constraintsNames = {c: c for c in lp.constraints} + return self.readsol_MPS(filename, lp, vs, variablesNames, constraintsNames) + + def get_status(self, filename): + cbcStatus = { + "Optimal": constants.LpStatusOptimal, + "Infeasible": constants.LpStatusInfeasible, + "Integer": constants.LpStatusInfeasible, + "Unbounded": constants.LpStatusUnbounded, + "Stopped": constants.LpStatusNotSolved, + } + + cbcSolStatus = { + "Optimal": constants.LpSolutionOptimal, + "Infeasible": constants.LpSolutionInfeasible, + "Unbounded": constants.LpSolutionUnbounded, + "Stopped": constants.LpSolutionNoSolutionFound, + } + + with open(filename) as f: + statusstrs = f.readline().split() + + status = cbcStatus.get(statusstrs[0], constants.LpStatusUndefined) + sol_status = cbcSolStatus.get( + statusstrs[0], constants.LpSolutionNoSolutionFound + ) + # here we could use some regex expression. + # Not sure what's more desirable + if status == constants.LpStatusNotSolved and len(statusstrs) >= 5: + if statusstrs[4] == "objective": + status = constants.LpStatusOptimal + sol_status = constants.LpSolutionIntegerFeasible + return status, sol_status + + +COIN = COIN_CMD + + +class PULP_CBC_CMD(COIN_CMD): + """ + This solver uses a precompiled version of cbc provided with the package + """ + + name = "PULP_CBC_CMD" + pulp_cbc_path = pulp_cbc_path + try: + if os.name != "nt": + if not os.access(pulp_cbc_path, os.X_OK): + import stat + + os.chmod(pulp_cbc_path, stat.S_IXUSR + stat.S_IXOTH) + except: # probably due to incorrect permissions + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp, callback=None): + """Solve a well formulated lp problem""" + raise PulpSolverError( + "PULP_CBC_CMD: Not Available (check permissions on %s)" + % self.pulp_cbc_path + ) + + else: + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + fracGap=None, + maxSeconds=None, + gapRel=None, + gapAbs=None, + presolve=None, + cuts=None, + strong=None, + options=None, + warmStart=False, + keepFiles=False, + path=None, + threads=None, + logPath=None, + mip_start=False, + timeMode="elapsed", + ): + if path is not None: + raise PulpSolverError("Use COIN_CMD if you want to set a path") + # check that the file is executable + COIN_CMD.__init__( + self, + path=self.pulp_cbc_path, + mip=mip, + msg=msg, + timeLimit=timeLimit, + fracGap=fracGap, + maxSeconds=maxSeconds, + gapRel=gapRel, + gapAbs=gapAbs, + presolve=presolve, + cuts=cuts, + strong=strong, + options=options, + warmStart=warmStart, + keepFiles=keepFiles, + threads=threads, + logPath=logPath, + mip_start=mip_start, + timeMode=timeMode, + ) + + +def COINMP_DLL_load_dll(path): + """ + function that loads the DLL useful for debugging installation problems + """ + if os.name == "nt": + lib = ctypes.windll.LoadLibrary(str(path[-1])) + else: + # linux hack to get working + mode = ctypes.RTLD_GLOBAL + for libpath in path[:-1]: + # RTLD_LAZY = 0x00001 + ctypes.CDLL(libpath, mode=mode) + lib = ctypes.CDLL(path[-1], mode=mode) + return lib + + +class COINMP_DLL(LpSolver): + """ + The COIN_MP LP MIP solver (via a DLL or linux so) + + :param timeLimit: The number of seconds before forcing the solver to exit + :param epgap: The fractional mip tolerance + """ + + name = "COINMP_DLL" + try: + lib = COINMP_DLL_load_dll(coinMP_path) + except (ImportError, OSError): + + @classmethod + def available(cls): + """True if the solver is available""" + return False + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + raise PulpSolverError("COINMP_DLL: Not Available") + + else: + COIN_INT_LOGLEVEL = 7 + COIN_REAL_MAXSECONDS = 16 + COIN_REAL_MIPMAXSEC = 19 + COIN_REAL_MIPFRACGAP = 34 + lib.CoinGetInfinity.restype = ctypes.c_double + lib.CoinGetVersionStr.restype = ctypes.c_char_p + lib.CoinGetSolutionText.restype = ctypes.c_char_p + lib.CoinGetObjectValue.restype = ctypes.c_double + lib.CoinGetMipBestBound.restype = ctypes.c_double + + def __init__( + self, + cuts=1, + presolve=1, + dual=1, + crash=0, + scale=1, + rounding=1, + integerPresolve=1, + strong=5, + epgap=None, + *args, + **kwargs, + ): + LpSolver.__init__(self, *args, **kwargs) + self.fracGap = None + if epgap is not None: + self.fracGap = float(epgap) + if self.timeLimit is not None: + self.timeLimit = float(self.timeLimit) + # Todo: these options are not yet implemented + self.cuts = cuts + self.presolve = presolve + self.dual = dual + self.crash = crash + self.scale = scale + self.rounding = rounding + self.integerPresolve = integerPresolve + self.strong = strong + + def copy(self): + """Make a copy of self""" + aCopy = LpSolver.copy(self) + aCopy.cuts = self.cuts + aCopy.presolve = self.presolve + aCopy.dual = self.dual + aCopy.crash = self.crash + aCopy.scale = self.scale + aCopy.rounding = self.rounding + aCopy.integerPresolve = self.integerPresolve + aCopy.strong = self.strong + return aCopy + + @classmethod + def available(cls): + """True if the solver is available""" + return True + + def getSolverVersion(self): + """ + returns a solver version string + + example: + >>> COINMP_DLL().getSolverVersion() # doctest: +ELLIPSIS + '...' + """ + return self.lib.CoinGetVersionStr() + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + # TODO alter so that msg parameter is handled correctly + self.debug = 0 + # initialise solver + self.lib.CoinInitSolver("") + # create problem + self.hProb = hProb = self.lib.CoinCreateProblem(lp.name) + # set problem options + self.lib.CoinSetIntOption( + hProb, self.COIN_INT_LOGLEVEL, ctypes.c_int(self.msg) + ) + + if self.timeLimit: + if self.mip: + self.lib.CoinSetRealOption( + hProb, self.COIN_REAL_MIPMAXSEC, ctypes.c_double(self.timeLimit) + ) + else: + self.lib.CoinSetRealOption( + hProb, + self.COIN_REAL_MAXSECONDS, + ctypes.c_double(self.timeLimit), + ) + if self.fracGap: + # Hopefully this is the bound gap tolerance + self.lib.CoinSetRealOption( + hProb, self.COIN_REAL_MIPFRACGAP, ctypes.c_double(self.fracGap) + ) + # CoinGetInfinity is needed for varibles with no bounds + coinDblMax = self.lib.CoinGetInfinity() + if self.debug: + print("Before getCoinMPArrays") + ( + numVars, + numRows, + numels, + rangeCount, + objectSense, + objectCoeffs, + objectConst, + rhsValues, + rangeValues, + rowType, + startsBase, + lenBase, + indBase, + elemBase, + lowerBounds, + upperBounds, + initValues, + colNames, + rowNames, + columnType, + n2v, + n2c, + ) = self.getCplexStyleArrays(lp) + self.lib.CoinLoadProblem( + hProb, + numVars, + numRows, + numels, + rangeCount, + objectSense, + objectConst, + objectCoeffs, + lowerBounds, + upperBounds, + rowType, + rhsValues, + rangeValues, + startsBase, + lenBase, + indBase, + elemBase, + colNames, + rowNames, + "Objective", + ) + if lp.isMIP() and self.mip: + self.lib.CoinLoadInteger(hProb, columnType) + + if self.msg == 0: + self.lib.CoinRegisterMsgLogCallback( + hProb, ctypes.c_char_p(""), ctypes.POINTER(ctypes.c_int)() + ) + self.coinTime = -clock() + self.lib.CoinOptimizeProblem(hProb, 0) + self.coinTime += clock() + + # TODO: check Integer Feasible status + CoinLpStatus = { + 0: constants.LpStatusOptimal, + 1: constants.LpStatusInfeasible, + 2: constants.LpStatusInfeasible, + 3: constants.LpStatusNotSolved, + 4: constants.LpStatusNotSolved, + 5: constants.LpStatusNotSolved, + -1: constants.LpStatusUndefined, + } + solutionStatus = self.lib.CoinGetSolutionStatus(hProb) + solutionText = self.lib.CoinGetSolutionText(hProb) + objectValue = self.lib.CoinGetObjectValue(hProb) + + # get the solution values + NumVarDoubleArray = ctypes.c_double * numVars + NumRowsDoubleArray = ctypes.c_double * numRows + cActivity = NumVarDoubleArray() + cReducedCost = NumVarDoubleArray() + cSlackValues = NumRowsDoubleArray() + cShadowPrices = NumRowsDoubleArray() + self.lib.CoinGetSolutionValues( + hProb, + ctypes.byref(cActivity), + ctypes.byref(cReducedCost), + ctypes.byref(cSlackValues), + ctypes.byref(cShadowPrices), + ) + + variablevalues = {} + variabledjvalues = {} + constraintpivalues = {} + constraintslackvalues = {} + if lp.isMIP() and self.mip: + lp.bestBound = self.lib.CoinGetMipBestBound(hProb) + for i in range(numVars): + variablevalues[self.n2v[i].name] = cActivity[i] + variabledjvalues[self.n2v[i].name] = cReducedCost[i] + lp.assignVarsVals(variablevalues) + lp.assignVarsDj(variabledjvalues) + # put pi and slack variables against the constraints + for i in range(numRows): + constraintpivalues[self.n2c[i]] = cShadowPrices[i] + constraintslackvalues[self.n2c[i]] = cSlackValues[i] + lp.assignConsPi(constraintpivalues) + lp.assignConsSlack(constraintslackvalues) + + self.lib.CoinFreeSolver() + status = CoinLpStatus[self.lib.CoinGetSolutionStatus(hProb)] + lp.assignStatus(status) + return status + + +if COINMP_DLL.available(): + COIN = COINMP_DLL + +yaposib = None + + +class YAPOSIB(LpSolver): + """ + COIN OSI (via its python interface) + + Copyright Christophe-Marie Duquesne 2012 + + The yaposib variables are available (after a solve) in var.solverVar + The yaposib constraints are available in constraint.solverConstraint + The Model is in prob.solverModel + """ + + name = "YAPOSIB" + try: + # import the model into the global scope + global yaposib + import yaposib + except ImportError: + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp, callback=None): + """Solve a well formulated lp problem""" + raise PulpSolverError("YAPOSIB: Not Available") + + else: + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + epgap=None, + solverName=None, + **solverParams, + ): + """ + Initializes the yaposib solver. + + @param mip: if False the solver will solve a MIP as + an LP + @param msg: displays information from the solver to + stdout + @param timeLimit: not supported + @param epgap: not supported + @param solverParams: not supported + """ + LpSolver.__init__(self, mip, msg) + if solverName: + self.solverName = solverName + else: + self.solverName = yaposib.available_solvers()[0] + + def findSolutionValues(self, lp): + model = lp.solverModel + solutionStatus = model.status + yaposibLpStatus = { + "optimal": constants.LpStatusOptimal, + "undefined": constants.LpStatusUndefined, + "abandoned": constants.LpStatusInfeasible, + "infeasible": constants.LpStatusInfeasible, + "limitreached": constants.LpStatusInfeasible, + } + # populate pulp solution values + for var in lp.variables(): + var.varValue = var.solverVar.solution + var.dj = var.solverVar.reducedcost + # put pi and slack variables against the constraints + for constr in lp.constraints.values(): + constr.pi = constr.solverConstraint.dual + constr.slack = -constr.constant - constr.solverConstraint.activity + if self.msg: + print("yaposib status=", solutionStatus) + lp.resolveOK = True + for var in lp.variables(): + var.isModified = False + status = yaposibLpStatus.get(solutionStatus, constants.LpStatusUndefined) + lp.assignStatus(status) + return status + + def available(self): + """True if the solver is available""" + return True + + def callSolver(self, lp, callback=None): + """Solves the problem with yaposib""" + savestdout = None + if self.msg == 0: + # close stdout to get rid of messages + tempfile = open(mktemp(), "w") + savestdout = os.dup(1) + os.close(1) + if os.dup(tempfile.fileno()) != 1: + raise PulpSolverError("couldn't redirect stdout - dup() error") + self.solveTime = -clock() + lp.solverModel.solve(self.mip) + self.solveTime += clock() + if self.msg == 0: + # reopen stdout + os.close(1) + os.dup(savestdout) + os.close(savestdout) + + def buildSolverModel(self, lp): + """ + Takes the pulp lp model and translates it into a yaposib model + """ + log.debug("create the yaposib model") + lp.solverModel = yaposib.Problem(self.solverName) + prob = lp.solverModel + prob.name = lp.name + log.debug("set the sense of the problem") + if lp.sense == constants.LpMaximize: + prob.obj.maximize = True + log.debug("add the variables to the problem") + for var in lp.variables(): + col = prob.cols.add(yaposib.vec([])) + col.name = var.name + if not var.lowBound is None: + col.lowerbound = var.lowBound + if not var.upBound is None: + col.upperbound = var.upBound + if var.cat == constants.LpInteger: + col.integer = True + prob.obj[col.index] = lp.objective.get(var, 0.0) + var.solverVar = col + log.debug("add the Constraints to the problem") + for name, constraint in lp.constraints.items(): + row = prob.rows.add( + yaposib.vec( + [ + (var.solverVar.index, value) + for var, value in constraint.items() + ] + ) + ) + if constraint.sense == constants.LpConstraintLE: + row.upperbound = -constraint.constant + elif constraint.sense == constants.LpConstraintGE: + row.lowerbound = -constraint.constant + elif constraint.sense == constants.LpConstraintEQ: + row.upperbound = -constraint.constant + row.lowerbound = -constraint.constant + else: + raise PulpSolverError("Detected an invalid constraint type") + row.name = name + constraint.solverConstraint = row + + def actualSolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + creates a yaposib model, variables and constraints and attaches + them to the lp model which it then solves + """ + self.buildSolverModel(lp) + # set the initial solution + log.debug("Solve the model using yaposib") + self.callSolver(lp, callback=callback) + # get the solution information + solutionStatus = self.findSolutionValues(lp) + for var in lp.variables(): + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus + + def actualResolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + uses the old solver and modifies the rhs of the modified + constraints + """ + log.debug("Resolve the model using yaposib") + for constraint in lp.constraints.values(): + row = constraint.solverConstraint + if constraint.modified: + if constraint.sense == constants.LpConstraintLE: + row.upperbound = -constraint.constant + elif constraint.sense == constants.LpConstraintGE: + row.lowerbound = -constraint.constant + elif constraint.sense == constants.LpConstraintEQ: + row.upperbound = -constraint.constant + row.lowerbound = -constraint.constant + else: + raise PulpSolverError("Detected an invalid constraint type") + self.callSolver(lp, callback=callback) + # get the solution information + solutionStatus = self.findSolutionValues(lp) + for var in lp.variables(): + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus diff --git a/utils/pulp/apis/copt_api.py b/utils/pulp/apis/copt_api.py new file mode 100644 index 0000000..b9c7a52 --- /dev/null +++ b/utils/pulp/apis/copt_api.py @@ -0,0 +1,1090 @@ +import os +import sys +import ctypes +import subprocess +import warnings + +from uuid import uuid4 +from .core import sparse, ctypesArrayFill, PulpSolverError +from .core import clock, log + +from .core import LpSolver, LpSolver_CMD +from ..constants import ( + LpStatusNotSolved, + LpStatusOptimal, + LpStatusInfeasible, + LpStatusUnbounded, + LpStatusUndefined, +) +from ..constants import LpContinuous, LpBinary, LpInteger +from ..constants import LpConstraintEQ, LpConstraintLE, LpConstraintGE +from ..constants import LpMinimize, LpMaximize + + +# COPT string convention +if sys.version_info >= (3, 0): + coptstr = lambda x: bytes(x, "utf-8") +else: + coptstr = lambda x: x + +byref = ctypes.byref + + +class COPT_CMD(LpSolver_CMD): + """ + The COPT command-line solver + """ + + name = "COPT_CMD" + + def __init__( + self, + path=None, + keepFiles=0, + mip=True, + msg=True, + mip_start=False, + warmStart=False, + logfile=None, + **params, + ): + """ + Initialize command-line solver + """ + if mip_start: + warnings.warn("Parameter mip_start is being depreciated for warmStart") + if warmStart: + warnings.warn( + "Parameter warmStart and mip_start passed, using warmStart" + ) + else: + warmStart = mip_start + + LpSolver_CMD.__init__(self, path, keepFiles, mip, msg, []) + + self.mipstart = warmStart + self.logfile = logfile + self.solverparams = params + + def defaultPath(self): + """ + The default path of 'copt_cmd' + """ + return self.executableExtension("copt_cmd") + + def available(self): + """ + True if 'copt_cmd' is available + """ + return self.executable(self.path) + + def actualSolve(self, lp): + """ + Solve a well formulated LP problem + + This function borrowed implementation of CPLEX_CMD.actualSolve and + GUROBI_CMD.actualSolve, with some modifications. + """ + if not self.available(): + raise PulpSolverError("COPT_PULP: Failed to execute '{}'".format(self.path)) + + if not self.keepFiles: + uuid = uuid4().hex + tmpLp = os.path.join(self.tmpDir, "{}-pulp.lp".format(uuid)) + tmpSol = os.path.join(self.tmpDir, "{}-pulp.sol".format(uuid)) + tmpMst = os.path.join(self.tmpDir, "{}-pulp.mst".format(uuid)) + else: + # Replace space with underscore to make filepath better + tmpName = lp.name + tmpName = tmpName.replace(" ", "_") + + tmpLp = tmpName + "-pulp.lp" + tmpSol = tmpName + "-pulp.sol" + tmpMst = tmpName + "-pulp.mst" + + lpvars = lp.writeLP(tmpLp, writeSOS=1) + + # Generate solving commands + solvecmds = self.path + solvecmds += " -c " + solvecmds += '"read ' + tmpLp + ";" + + if lp.isMIP() and self.mipstart: + self.writemst(tmpMst, lpvars) + solvecmds += "read " + tmpMst + ";" + + if self.logfile is not None: + solvecmds += "set logfile {};".format(self.logfile) + + if self.solverparams is not None: + for parname, parval in self.solverparams.items(): + solvecmds += "set {0} {1};".format(parname, parval) + + if lp.isMIP() and not self.mip: + solvecmds += "optimizelp;" + else: + solvecmds += "optimize;" + + solvecmds += "write " + tmpSol + ";" + solvecmds += 'exit"' + + try: + os.remove(tmpSol) + except: + pass + + if self.msg: + msgpipe = None + else: + msgpipe = open(os.devnull, "w") + + rc = subprocess.call(solvecmds, shell=True, stdout=msgpipe, stderr=msgpipe) + + if msgpipe is not None: + msgpipe.close() + + # Get and analyze result + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to execute '{}'".format(self.path)) + + if not os.path.exists(tmpSol): + status = LpStatusNotSolved + else: + status, values = self.readsol(tmpSol) + + if not self.keepFiles: + for oldfile in [tmpLp, tmpSol, tmpMst]: + try: + os.remove(oldfile) + except: + pass + + if status == LpStatusOptimal: + lp.assignVarsVals(values) + + # lp.assignStatus(status) + lp.status = status + + return status + + def readsol(self, filename): + """ + Read COPT solution file + """ + with open(filename) as solfile: + try: + next(solfile) + except StopIteration: + warnings.warn("COPT_PULP: No solution was returned") + return LpStatusNotSolved, {} + + # TODO: No information about status, assumed to be optimal + status = LpStatusOptimal + + values = {} + for line in solfile: + if line[0] != "#": + varname, varval = line.split() + values[varname] = float(varval) + return status, values + + def writemst(self, filename, lpvars): + """ + Write COPT MIP start file + """ + mstvals = [(v.name, v.value()) for v in lpvars if v.value() is not None] + mstline = [] + for varname, varval in mstvals: + mstline.append("{0} {1}".format(varname, varval)) + + with open(filename, "w") as mstfile: + mstfile.write("\n".join(mstline)) + return True + + +def COPT_DLL_loadlib(): + """ + Load COPT shared library in all supported platforms + """ + from glob import glob + + libfile = None + libpath = None + libhome = os.getenv("COPT_HOME") + + if sys.platform == "win32": + libfile = glob(os.path.join(libhome, "bin", "copt.dll")) + elif sys.platform == "linux": + libfile = glob(os.path.join(libhome, "lib", "libcopt.so")) + elif sys.platform == "darwin": + libfile = glob(os.path.join(libhome, "lib", "libcopt.dylib")) + else: + raise PulpSolverError("COPT_PULP: Unsupported operating system") + + # Find desired library in given search path + if libfile: + libpath = libfile[0] + + if libpath is None: + raise PulpSolverError( + "COPT_PULP: Failed to locate solver library, " + "please refer to COPT manual for installation guide" + ) + else: + if sys.platform == "win32": + coptlib = ctypes.windll.LoadLibrary(libpath) + else: + coptlib = ctypes.cdll.LoadLibrary(libpath) + + return coptlib + + +# COPT LP/MIP status map +coptlpstat = { + 0: LpStatusNotSolved, + 1: LpStatusOptimal, + 2: LpStatusInfeasible, + 3: LpStatusUnbounded, + 4: LpStatusNotSolved, + 5: LpStatusNotSolved, + 6: LpStatusNotSolved, + 8: LpStatusNotSolved, + 9: LpStatusNotSolved, + 10: LpStatusNotSolved, +} + +# COPT variable types map +coptctype = { + LpContinuous: coptstr("C"), + LpBinary: coptstr("B"), + LpInteger: coptstr("I"), +} + +# COPT constraint types map +coptrsense = { + LpConstraintEQ: coptstr("E"), + LpConstraintLE: coptstr("L"), + LpConstraintGE: coptstr("G"), +} + +# COPT objective senses map +coptobjsen = {LpMinimize: 1, LpMaximize: -1} + + +class COPT_DLL(LpSolver): + """ + The COPT dynamic library solver + """ + + name = "COPT_DLL" + + try: + coptlib = COPT_DLL_loadlib() + except Exception as e: + err = e + """The COPT dynamic library solver (DLL). Something went wrong!!!!""" + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + raise PulpSolverError(f"COPT_DLL: Not Available:\n{self.err}") + + else: + # COPT API name map + CreateEnv = coptlib.COPT_CreateEnv + DeleteEnv = coptlib.COPT_DeleteEnv + CreateProb = coptlib.COPT_CreateProb + DeleteProb = coptlib.COPT_DeleteProb + LoadProb = coptlib.COPT_LoadProb + AddCols = coptlib.COPT_AddCols + WriteMps = coptlib.COPT_WriteMps + WriteLp = coptlib.COPT_WriteLp + WriteBin = coptlib.COPT_WriteBin + WriteSol = coptlib.COPT_WriteSol + WriteBasis = coptlib.COPT_WriteBasis + WriteMst = coptlib.COPT_WriteMst + WriteParam = coptlib.COPT_WriteParam + AddMipStart = coptlib.COPT_AddMipStart + SolveLp = coptlib.COPT_SolveLp + Solve = coptlib.COPT_Solve + GetSolution = coptlib.COPT_GetSolution + GetLpSolution = coptlib.COPT_GetLpSolution + GetIntParam = coptlib.COPT_GetIntParam + SetIntParam = coptlib.COPT_SetIntParam + GetDblParam = coptlib.COPT_GetDblParam + SetDblParam = coptlib.COPT_SetDblParam + GetIntAttr = coptlib.COPT_GetIntAttr + GetDblAttr = coptlib.COPT_GetDblAttr + SearchParamAttr = coptlib.COPT_SearchParamAttr + SetLogFile = coptlib.COPT_SetLogFile + + def __init__( + self, + mip=True, + msg=True, + mip_start=False, + warmStart=False, + logfile=None, + **params, + ): + """ + Initialize COPT solver + """ + if mip_start: + warnings.warn("Parameter mip_start is being depreciated for warmStart") + if warmStart: + warnings.warn( + "Parameter warmStart and mip_start passed, using warmStart" + ) + else: + warmStart = mip_start + + LpSolver.__init__(self, mip, msg) + + # Initialize COPT environment and problem + self.coptenv = None + self.coptprob = None + + # Use MIP start information + self.mipstart = warmStart + + # Create COPT environment and problem + self.create() + + # Set log file + if logfile is not None: + rc = self.SetLogFile(self.coptprob, coptstr(logfile)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to set log file") + + # Set parameters to problem + if not self.msg: + self.setParam("Logging", 0) + + for parname, parval in params.items(): + self.setParam(parname, parval) + + def available(self): + """ + True if dynamic library is available + """ + return True + + def actualSolve(self, lp): + """ + Solve a well formulated LP/MIP problem + + This function borrowed implementation of CPLEX_DLL.actualSolve, + with some modifications. + """ + # Extract problem data and load it into COPT + ( + ncol, + nrow, + nnonz, + objsen, + objconst, + colcost, + colbeg, + colcnt, + colind, + colval, + coltype, + collb, + colub, + rowsense, + rowrhs, + colname, + rowname, + ) = self.extract(lp) + + rc = self.LoadProb( + self.coptprob, + ncol, + nrow, + objsen, + objconst, + colcost, + colbeg, + colcnt, + colind, + colval, + coltype, + collb, + colub, + rowsense, + rowrhs, + None, + colname, + rowname, + ) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to load problem") + + if lp.isMIP() and self.mip: + # Load MIP start information + if self.mipstart: + mstdict = { + self.v2n[v]: v.value() + for v in lp.variables() + if v.value() is not None + } + + if mstdict: + mstkeys = ctypesArrayFill(list(mstdict.keys()), ctypes.c_int) + mstvals = ctypesArrayFill( + list(mstdict.values()), ctypes.c_double + ) + + rc = self.AddMipStart( + self.coptprob, len(mstkeys), mstkeys, mstvals + ) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to add MIP start information" + ) + + # Solve the problem + rc = self.Solve(self.coptprob) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to solve the MIP problem") + elif lp.isMIP() and not self.mip: + # Solve MIP as LP + rc = self.SolveLp(self.coptprob) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to solve MIP as LP") + else: + # Solve the LP problem + rc = self.SolveLp(self.coptprob) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to solve the LP problem") + + # Get problem status and solution + status = self.getsolution(lp, ncol, nrow) + + # Reset attributes + for var in lp.variables(): + var.modified = False + + return status + + def extract(self, lp): + """ + Extract data from PuLP lp structure + + This function borrowed implementation of LpSolver.getCplexStyleArrays, + with some modifications. + """ + cols = list(lp.variables()) + ncol = len(cols) + nrow = len(lp.constraints) + + collb = (ctypes.c_double * ncol)() + colub = (ctypes.c_double * ncol)() + colcost = (ctypes.c_double * ncol)() + coltype = (ctypes.c_char * ncol)() + colname = (ctypes.c_char_p * ncol)() + + rowrhs = (ctypes.c_double * nrow)() + rowsense = (ctypes.c_char * nrow)() + rowname = (ctypes.c_char_p * nrow)() + + spmat = sparse.Matrix(list(range(nrow)), list(range(ncol))) + + # Objective sense and constant offset + objsen = coptobjsen[lp.sense] + objconst = ctypes.c_double(0.0) + + # Associate each variable with a ordinal + self.v2n = dict(((cols[i], i) for i in range(ncol))) + self.vname2n = dict(((cols[i].name, i) for i in range(ncol))) + self.n2v = dict((i, cols[i]) for i in range(ncol)) + self.c2n = {} + self.n2c = {} + self.addedVars = ncol + self.addedRows = nrow + + # Extract objective cost + for col, val in lp.objective.items(): + colcost[self.v2n[col]] = val + + # Extract variable types, names and lower/upper bounds + for col in lp.variables(): + colname[self.v2n[col]] = coptstr(col.name) + + if col.lowBound is not None: + collb[self.v2n[col]] = col.lowBound + else: + collb[self.v2n[col]] = -1e30 + + if col.upBound is not None: + colub[self.v2n[col]] = col.upBound + else: + colub[self.v2n[col]] = 1e30 + + # Extract column types + if lp.isMIP(): + for var in lp.variables(): + coltype[self.v2n[var]] = coptctype[var.cat] + else: + coltype = None + + # Extract constraint rhs, senses and names + idx = 0 + for row in lp.constraints: + rowrhs[idx] = -lp.constraints[row].constant + rowsense[idx] = coptrsense[lp.constraints[row].sense] + rowname[idx] = coptstr(row) + + self.c2n[row] = idx + self.n2c[idx] = row + idx += 1 + + # Extract coefficient matrix and generate CSC-format matrix + for col, row, coeff in lp.coefficients(): + spmat.add(self.c2n[row], self.vname2n[col], coeff) + + nnonz, _colbeg, _colcnt, _colind, _colval = spmat.col_based_arrays() + + colbeg = ctypesArrayFill(_colbeg, ctypes.c_int) + colcnt = ctypesArrayFill(_colcnt, ctypes.c_int) + colind = ctypesArrayFill(_colind, ctypes.c_int) + colval = ctypesArrayFill(_colval, ctypes.c_double) + + return ( + ncol, + nrow, + nnonz, + objsen, + objconst, + colcost, + colbeg, + colcnt, + colind, + colval, + coltype, + collb, + colub, + rowsense, + rowrhs, + colname, + rowname, + ) + + def create(self): + """ + Create COPT environment and problem + + This function borrowed implementation of CPLEX_DLL.grabLicense, + with some modifications. + """ + # In case recreate COPT environment and problem + self.delete() + + self.coptenv = ctypes.c_void_p() + self.coptprob = ctypes.c_void_p() + + # Create COPT environment + rc = self.CreateEnv(byref(self.coptenv)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to create environment") + + # Create COPT problem + rc = self.CreateProb(self.coptenv, byref(self.coptprob)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to create problem") + + def __del__(self): + """ + Destructor of COPT_DLL class + """ + self.delete() + + def delete(self): + """ + Release COPT problem and environment + + This function borrowed implementation of CPLEX_DLL.releaseLicense, + with some modifications. + """ + # Valid environment and problem exist + if self.coptenv is not None and self.coptprob is not None: + # Delete problem + rc = self.DeleteProb(byref(self.coptprob)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to delete problem") + + # Delete environment + rc = self.DeleteEnv(byref(self.coptenv)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to delete environment") + + # Reset to None + self.coptenv = None + self.coptprob = None + + def getsolution(self, lp, ncols, nrows): + """Get problem solution + + This function borrowed implementation of CPLEX_DLL.findSolutionValues, + with some modifications. + """ + status = ctypes.c_int() + x = (ctypes.c_double * ncols)() + dj = (ctypes.c_double * ncols)() + pi = (ctypes.c_double * nrows)() + slack = (ctypes.c_double * nrows)() + + var_x = {} + var_dj = {} + con_pi = {} + con_slack = {} + + if lp.isMIP() and self.mip: + hasmipsol = ctypes.c_int() + # Get MIP problem satus + rc = self.GetIntAttr(self.coptprob, coptstr("MipStatus"), byref(status)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to get MIP status") + # Has MIP solution + rc = self.GetIntAttr( + self.coptprob, coptstr("HasMipSol"), byref(hasmipsol) + ) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to check if MIP solution exists" + ) + + # Optimal/Feasible MIP solution + if status.value == 1 or hasmipsol.value == 1: + rc = self.GetSolution(self.coptprob, byref(x)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to get MIP solution") + + for i in range(ncols): + var_x[self.n2v[i].name] = x[i] + + # Assign MIP solution to variables + lp.assignVarsVals(var_x) + else: + # Get LP problem status + rc = self.GetIntAttr(self.coptprob, coptstr("LpStatus"), byref(status)) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to get LP status") + + # Optimal LP solution + if status.value == 1: + rc = self.GetLpSolution( + self.coptprob, byref(x), byref(slack), byref(pi), byref(dj) + ) + if rc != 0: + raise PulpSolverError("COPT_PULP: Failed to get LP solution") + + for i in range(ncols): + var_x[self.n2v[i].name] = x[i] + var_dj[self.n2v[i].name] = dj[i] + + # NOTE: slacks in COPT are activities of rows + for i in range(nrows): + con_pi[self.n2c[i]] = pi[i] + con_slack[self.n2c[i]] = slack[i] + + # Assign LP solution to variables and constraints + lp.assignVarsVals(var_x) + lp.assignVarsDj(var_dj) + lp.assignConsPi(con_pi) + lp.assignConsSlack(con_slack) + + # Reset attributes + lp.resolveOK = True + for var in lp.variables(): + var.isModified = False + + lp.status = coptlpstat.get(status.value, LpStatusUndefined) + return lp.status + + def write(self, filename): + """ + Write problem, basis, parameter or solution to file + """ + file_path = coptstr(filename) + file_name, file_ext = os.path.splitext(file_path) + + if not file_ext: + raise PulpSolverError("COPT_PULP: Failed to determine output file type") + elif file_ext == coptstr(".mps"): + rc = self.WriteMps(self.coptprob, file_path) + elif file_ext == coptstr(".lp"): + rc = self.WriteLp(self.coptprob, file_path) + elif file_ext == coptstr(".bin"): + rc = self.WriteBin(self.coptprob, file_path) + elif file_ext == coptstr(".sol"): + rc = self.WriteSol(self.coptprob, file_path) + elif file_ext == coptstr(".bas"): + rc = self.WriteBasis(self.coptprob, file_path) + elif file_ext == coptstr(".mst"): + rc = self.WriteMst(self.coptprob, file_path) + elif file_ext == coptstr(".par"): + rc = self.WriteParam(self.coptprob, file_path) + else: + raise PulpSolverError("COPT_PULP: Unsupported file type") + + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to write file '{}'".format(filename) + ) + + def setParam(self, name, val): + """ + Set parameter to COPT problem + """ + par_type = ctypes.c_int() + par_name = coptstr(name) + + rc = self.SearchParamAttr(self.coptprob, par_name, byref(par_type)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to check type for '{}'".format(par_name) + ) + + if par_type.value == 0: + rc = self.SetDblParam(self.coptprob, par_name, ctypes.c_double(val)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to set double parameter '{}'".format( + par_name + ) + ) + elif par_type.value == 1: + rc = self.SetIntParam(self.coptprob, par_name, ctypes.c_int(val)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to set integer parameter '{}'".format( + par_name + ) + ) + else: + raise PulpSolverError( + "COPT_PULP: Invalid parameter '{}'".format(par_name) + ) + + def getParam(self, name): + """ + Get current value of parameter + """ + par_dblval = ctypes.c_double() + par_intval = ctypes.c_int() + par_type = ctypes.c_int() + par_name = coptstr(name) + + rc = self.SearchParamAttr(self.coptprob, par_name, byref(par_type)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to check type for '{}'".format(par_name) + ) + + if par_type.value == 0: + rc = self.GetDblParam(self.coptprob, par_name, byref(par_dblval)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to get double parameter '{}'".format( + par_name + ) + ) + else: + retval = par_dblval.value + elif par_type.value == 1: + rc = self.GetIntParam(self.coptprob, par_name, byref(par_intval)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to get integer parameter '{}'".format( + par_name + ) + ) + else: + retval = par_intval.value + else: + raise PulpSolverError( + "COPT_PULP: Invalid parameter '{}'".format(par_name) + ) + + return retval + + def getAttr(self, name): + """ + Get attribute of the problem + """ + attr_dblval = ctypes.c_double() + attr_intval = ctypes.c_int() + attr_type = ctypes.c_int() + attr_name = coptstr(name) + + # Check attribute type by name + rc = self.SearchParamAttr(self.coptprob, attr_name, byref(attr_type)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to check type for '{}'".format(attr_name) + ) + + if attr_type.value == 2: + rc = self.GetDblAttr(self.coptprob, attr_name, byref(attr_dblval)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to get double attribute '{}'".format( + attr_name + ) + ) + else: + retval = attr_dblval.value + elif attr_type.value == 3: + rc = self.GetIntAttr(self.coptprob, attr_name, byref(attr_intval)) + if rc != 0: + raise PulpSolverError( + "COPT_PULP: Failed to get integer attribute '{}'".format( + attr_name + ) + ) + else: + retval = attr_intval.value + else: + raise PulpSolverError( + "COPT_PULP: Invalid attribute '{}'".format(attr_name) + ) + + return retval + + +class COPT(LpSolver): + """ + The COPT Optimizer via its python interface + + The COPT variables are available (after a solve) in var.solverVar + Constraints in constraint.solverConstraint + and the Model is in prob.solverModel + """ + + name = "COPT" + + try: + global coptpy + import coptpy + except: + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp, callback=None): + """Solve a well formulated lp problem""" + raise PulpSolverError("COPT: Not available") + + else: + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + epgap=None, + gapRel=None, + warmStart=False, + logPath=None, + **solverParams, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param bool warmStart: if True, the solver will use the current value of variables as a start + :param str logPath: path to the log file + :param float epgap: deprecated for gapRel + """ + if epgap is not None: + warnings.warn("Parameter epgap is being depreciated for gapRel") + if gapRel is not None: + warnings.warn("Parameter gapRel and epgap passed, using gapRel") + else: + gapRel = epgap + + LpSolver.__init__( + self, + mip=mip, + msg=msg, + timeLimit=timeLimit, + gapRel=gapRel, + logPath=logPath, + warmStart=warmStart, + ) + + self.coptenv = coptpy.Envr() + self.coptmdl = self.coptenv.createModel() + + if not self.msg: + self.coptmdl.setParam("Logging", 0) + for key, value in solverParams.items(): + self.coptmdl.setParam(key, value) + + def findSolutionValues(self, lp): + model = lp.solverModel + solutionStatus = model.status + + CoptLpStatus = { + coptpy.COPT.UNSTARTED: LpStatusNotSolved, + coptpy.COPT.OPTIMAL: LpStatusOptimal, + coptpy.COPT.INFEASIBLE: LpStatusInfeasible, + coptpy.COPT.UNBOUNDED: LpStatusUnbounded, + coptpy.COPT.INF_OR_UNB: LpStatusInfeasible, + coptpy.COPT.NUMERICAL: LpStatusNotSolved, + coptpy.COPT.NODELIMIT: LpStatusNotSolved, + coptpy.COPT.TIMEOUT: LpStatusNotSolved, + coptpy.COPT.UNFINISHED: LpStatusNotSolved, + coptpy.COPT.INTERRUPTED: LpStatusNotSolved, + } + + if self.msg: + print("COPT status=", solutionStatus) + + lp.resolveOK = True + for var in lp._variables: + var.isModified = False + + status = CoptLpStatus.get(solutionStatus, LpStatusUndefined) + lp.assignStatus(status) + if status != LpStatusOptimal: + return status + + values = model.getInfo("Value", model.getVars()) + for var, value in zip(lp._variables, values): + var.varValue = value + + if not model.ismip: + # NOTE: slacks in COPT are activities of rows + slacks = model.getInfo("Slack", model.getConstrs()) + for constr, value in zip(lp.constraints.values(), slacks): + constr.slack = value + + redcosts = model.getInfo("RedCost", model.getVars()) + for var, value in zip(lp._variables, redcosts): + var.dj = value + + duals = model.getInfo("Dual", model.getConstrs()) + for constr, value in zip(lp.constraints.values(), duals): + constr.pi = value + + return status + + def available(self): + """True if the solver is available""" + return True + + def callSolver(self, lp, callback=None): + """Solves the problem with COPT""" + self.solveTime = -clock() + if callback is not None: + lp.solverModel.setCallback( + callback, + coptpy.COPT.CBCONTEXT_MIPRELAX | coptpy.COPT.CBCONTEXT_MIPSOL, + ) + lp.solverModel.solve() + self.solveTime += clock() + + def buildSolverModel(self, lp): + """ + Takes the pulp lp model and translates it into a COPT model + """ + lp.solverModel = self.coptmdl + + if lp.sense == LpMaximize: + lp.solverModel.objsense = coptpy.COPT.MAXIMIZE + if self.timeLimit: + lp.solverModel.setParam("TimeLimit", self.timeLimit) + + gapRel = self.optionsDict.get("gapRel") + logPath = self.optionsDict.get("logPath") + if gapRel: + lp.solverModel.setParam("RelGap", gapRel) + if logPath: + lp.solverModel.setLogFile(logPath) + + for var in lp.variables(): + lowBound = var.lowBound + if lowBound is None: + lowBound = -coptpy.COPT.INFINITY + upBound = var.upBound + if upBound is None: + upBound = coptpy.COPT.INFINITY + obj = lp.objective.get(var, 0.0) + varType = coptpy.COPT.CONTINUOUS + if var.cat == LpInteger and self.mip: + varType = coptpy.COPT.INTEGER + var.solverVar = lp.solverModel.addVar( + lowBound, upBound, vtype=varType, obj=obj, name=var.name + ) + + if self.optionsDict.get("warmStart", False): + for var in lp._variables: + if var.varValue is not None: + self.coptmdl.setMipStart(var.solverVar, var.varValue) + self.coptmdl.loadMipStart() + + for name, constraint in lp.constraints.items(): + expr = coptpy.LinExpr( + [v.solverVar for v in constraint.keys()], list(constraint.values()) + ) + if constraint.sense == LpConstraintLE: + relation = coptpy.COPT.LESS_EQUAL + elif constraint.sense == LpConstraintGE: + relation = coptpy.COPT.GREATER_EQUAL + elif constraint.sense == LpConstraintEQ: + relation = coptpy.COPT.EQUAL + else: + raise PulpSolverError("Detected an invalid constraint type") + constraint.solverConstraint = lp.solverModel.addConstr( + expr, relation, -constraint.constant, name + ) + + def actualSolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + creates a COPT model, variables and constraints and attaches + them to the lp model which it then solves + """ + self.buildSolverModel(lp) + self.callSolver(lp, callback=callback) + + solutionStatus = self.findSolutionValues(lp) + for var in lp._variables: + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus + + def actualResolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + uses the old solver and modifies the rhs of the modified constraints + """ + for constraint in lp.constraints.values(): + if constraint.modified: + if constraint.sense == LpConstraintLE: + constraint.solverConstraint.ub = -constraint.constant + elif constraint.sense == LpConstraintGE: + constraint.solverConstraint.lb = -constraint.constant + else: + constraint.solverConstraint.lb = -constraint.constant + constraint.solverConstraint.ub = -constraint.constant + + self.callSolver(lp, callback=callback) + + solutionStatus = self.findSolutionValues(lp) + for var in lp._variables: + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus diff --git a/utils/pulp/apis/core.py b/utils/pulp/apis/core.py new file mode 100644 index 0000000..23570e4 --- /dev/null +++ b/utils/pulp/apis/core.py @@ -0,0 +1,493 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +""" +This file contains the solver classes for PuLP +Note that the solvers that require a compiled extension may not work in +the current version +""" + +import os +import platform +import shutil +import sys +import ctypes + + +from time import monotonic as clock + +import configparser +from typing import Union + +Parser = configparser.ConfigParser + +from .. import sparse +from .. import constants as const + +import logging + +try: + import ujson as json +except ImportError: + import json + +log = logging.getLogger(__name__) + +import subprocess + +devnull = subprocess.DEVNULL +to_string = lambda _obj: str(_obj).encode() + +from uuid import uuid4 + + +class PulpSolverError(const.PulpError): + """ + Pulp Solver-related exceptions + """ + + pass + + +# import configuration information +def initialize(filename, operating_system="linux", arch="64"): + """reads the configuration file to initialise the module""" + here = os.path.dirname(filename) + config = Parser({"here": here, "os": operating_system, "arch": arch}) + config.read(filename) + + try: + cplex_dll_path = config.get("locations", "CplexPath") + except configparser.Error: + cplex_dll_path = "libcplex110.so" + try: + try: + ilm_cplex_license = ( + config.get("licenses", "ilm_cplex_license") + .decode("string-escape") + .replace('"', "") + ) + except AttributeError: + ilm_cplex_license = config.get("licenses", "ilm_cplex_license").replace( + '"', "" + ) + except configparser.Error: + ilm_cplex_license = "" + try: + ilm_cplex_license_signature = config.getint( + "licenses", "ilm_cplex_license_signature" + ) + except configparser.Error: + ilm_cplex_license_signature = 0 + try: + coinMP_path = config.get("locations", "CoinMPPath").split(", ") + except configparser.Error: + coinMP_path = ["libCoinMP.so"] + try: + gurobi_path = config.get("locations", "GurobiPath") + except configparser.Error: + gurobi_path = "/opt/gurobi201/linux32/lib/python2.5" + try: + cbc_path = config.get("locations", "CbcPath") + except configparser.Error: + cbc_path = "cbc" + try: + glpk_path = config.get("locations", "GlpkPath") + except configparser.Error: + glpk_path = "glpsol" + try: + pulp_cbc_path = config.get("locations", "PulpCbcPath") + except configparser.Error: + pulp_cbc_path = "cbc" + try: + scip_path = config.get("locations", "ScipPath") + except configparser.Error: + scip_path = "scip" + try: + fscip_path = config.get("locations", "FscipPath") + except configparser.Error: + fscip_path = "fscip" + for i, path in enumerate(coinMP_path): + if not os.path.dirname(path): + # if no pathname is supplied assume the file is in the same directory + coinMP_path[i] = os.path.join(os.path.dirname(config_filename), path) + return ( + cplex_dll_path, + ilm_cplex_license, + ilm_cplex_license_signature, + coinMP_path, + gurobi_path, + cbc_path, + glpk_path, + pulp_cbc_path, + scip_path, + fscip_path, + ) + + +# pick up the correct config file depending on operating system +PULPCFGFILE = "pulp.cfg" +is_64bits = sys.maxsize > 2**32 +if is_64bits: + arch = "64" + if platform.machine().lower() in ["aarch64", "arm64"]: + arch = "arm64" +else: + arch = "32" +operating_system = None +if sys.platform in ["win32", "cli"]: + operating_system = "win" + PULPCFGFILE += ".win" +elif sys.platform in ["darwin"]: + operating_system = "osx" + arch = "64" + PULPCFGFILE += ".osx" +else: + operating_system = "linux" + PULPCFGFILE += ".linux" + +DIRNAME = os.path.dirname(__file__) +config_filename = os.path.normpath(os.path.join(DIRNAME, "..", PULPCFGFILE)) +( + cplex_dll_path, + ilm_cplex_license, + ilm_cplex_license_signature, + coinMP_path, + gurobi_path, + cbc_path, + glpk_path, + pulp_cbc_path, + scip_path, + fscip_path, +) = initialize(config_filename, operating_system, arch) + + +class LpSolver: + """A generic LP Solver""" + + name = "LpSolver" + + def __init__( + self, mip=True, msg=True, options=None, timeLimit=None, *args, **kwargs + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param list options: + :param float timeLimit: maximum time for solver (in seconds) + :param args: + :param kwargs: optional named options to pass to each solver, + e.g. gapRel=0.1, gapAbs=10, logPath="", + """ + if options is None: + options = [] + self.mip = mip + self.msg = msg + self.options = options + self.timeLimit = timeLimit + + # here we will store all other relevant information including: + # gapRel, gapAbs, maxMemory, maxNodes, threads, logPath, timeMode + self.optionsDict = {k: v for k, v in kwargs.items() if v is not None} + + def available(self): + """True if the solver is available""" + raise NotImplementedError + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + raise NotImplementedError + + def actualResolve(self, lp, **kwargs): + """ + uses existing problem information and solves the problem + If it is not implemented in the solver + just solve again + """ + self.actualSolve(lp, **kwargs) + + def copy(self): + """Make a copy of self""" + + aCopy = self.__class__() + aCopy.mip = self.mip + aCopy.msg = self.msg + aCopy.options = self.options + return aCopy + + def solve(self, lp): + """Solve the problem lp""" + # Always go through the solve method of LpProblem + return lp.solve(self) + + # TODO: Not sure if this code should be here or in a child class + def getCplexStyleArrays( + self, lp, senseDict=None, LpVarCategories=None, LpObjSenses=None, infBound=1e20 + ): + """returns the arrays suitable to pass to a cdll Cplex + or other solvers that are similar + + Copyright (c) Stuart Mitchell 2007 + """ + if senseDict is None: + senseDict = { + const.LpConstraintEQ: "E", + const.LpConstraintLE: "L", + const.LpConstraintGE: "G", + } + if LpVarCategories is None: + LpVarCategories = {const.LpContinuous: "C", const.LpInteger: "I"} + if LpObjSenses is None: + LpObjSenses = {const.LpMaximize: -1, const.LpMinimize: 1} + + import ctypes + + rangeCount = 0 + variables = list(lp.variables()) + numVars = len(variables) + # associate each variable with a ordinal + self.v2n = {variables[i]: i for i in range(numVars)} + self.vname2n = {variables[i].name: i for i in range(numVars)} + self.n2v = {i: variables[i] for i in range(numVars)} + # objective values + objSense = LpObjSenses[lp.sense] + NumVarDoubleArray = ctypes.c_double * numVars + objectCoeffs = NumVarDoubleArray() + # print "Get objective Values" + for v, val in lp.objective.items(): + objectCoeffs[self.v2n[v]] = val + # values for variables + objectConst = ctypes.c_double(0.0) + NumVarStrArray = ctypes.c_char_p * numVars + colNames = NumVarStrArray() + lowerBounds = NumVarDoubleArray() + upperBounds = NumVarDoubleArray() + initValues = NumVarDoubleArray() + for v in lp.variables(): + colNames[self.v2n[v]] = to_string(v.name) + initValues[self.v2n[v]] = 0.0 + if v.lowBound != None: + lowerBounds[self.v2n[v]] = v.lowBound + else: + lowerBounds[self.v2n[v]] = -infBound + if v.upBound != None: + upperBounds[self.v2n[v]] = v.upBound + else: + upperBounds[self.v2n[v]] = infBound + # values for constraints + numRows = len(lp.constraints) + NumRowDoubleArray = ctypes.c_double * numRows + NumRowStrArray = ctypes.c_char_p * numRows + NumRowCharArray = ctypes.c_char * numRows + rhsValues = NumRowDoubleArray() + rangeValues = NumRowDoubleArray() + rowNames = NumRowStrArray() + rowType = NumRowCharArray() + self.c2n = {} + self.n2c = {} + i = 0 + for c in lp.constraints: + rhsValues[i] = -lp.constraints[c].constant + # for ranged constraints a<= constraint >=b + rangeValues[i] = 0.0 + rowNames[i] = to_string(c) + rowType[i] = to_string(senseDict[lp.constraints[c].sense]) + self.c2n[c] = i + self.n2c[i] = c + i = i + 1 + # return the coefficient matrix as a series of vectors + coeffs = lp.coefficients() + sparseMatrix = sparse.Matrix(list(range(numRows)), list(range(numVars))) + for var, row, coeff in coeffs: + sparseMatrix.add(self.c2n[row], self.vname2n[var], coeff) + ( + numels, + mystartsBase, + mylenBase, + myindBase, + myelemBase, + ) = sparseMatrix.col_based_arrays() + elemBase = ctypesArrayFill(myelemBase, ctypes.c_double) + indBase = ctypesArrayFill(myindBase, ctypes.c_int) + startsBase = ctypesArrayFill(mystartsBase, ctypes.c_int) + lenBase = ctypesArrayFill(mylenBase, ctypes.c_int) + # MIP Variables + NumVarCharArray = ctypes.c_char * numVars + columnType = NumVarCharArray() + if lp.isMIP(): + for v in lp.variables(): + columnType[self.v2n[v]] = to_string(LpVarCategories[v.cat]) + self.addedVars = numVars + self.addedRows = numRows + return ( + numVars, + numRows, + numels, + rangeCount, + objSense, + objectCoeffs, + objectConst, + rhsValues, + rangeValues, + rowType, + startsBase, + lenBase, + indBase, + elemBase, + lowerBounds, + upperBounds, + initValues, + colNames, + rowNames, + columnType, + self.n2v, + self.n2c, + ) + + def toDict(self): + data = dict(solver=self.name) + for k in ["mip", "msg", "keepFiles"]: + try: + data[k] = getattr(self, k) + except AttributeError: + pass + for k in ["timeLimit", "options"]: + # with these ones, we only export if it has some content: + try: + value = getattr(self, k) + if value: + data[k] = value + except AttributeError: + pass + data.update(self.optionsDict) + return data + + to_dict = toDict + + def toJson(self, filename, *args, **kwargs): + with open(filename, "w") as f: + json.dump(self.toDict(), f, *args, **kwargs) + + to_json = toJson + + +class LpSolver_CMD(LpSolver): + """A generic command line LP Solver""" + + name = "LpSolver_CMD" + + def __init__(self, path=None, keepFiles=False, *args, **kwargs): + """ + + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param list options: list of additional options to pass to solver (format depends on the solver) + :param float timeLimit: maximum time for solver (in seconds) + :param str path: a path to the solver binary + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param args: parameters to pass to :py:class:`LpSolver` + :param kwargs: parameters to pass to :py:class:`LpSolver` + """ + LpSolver.__init__(self, *args, **kwargs) + if path is None: + self.path = self.defaultPath() + else: + self.path = path + self.keepFiles = keepFiles + self.setTmpDir() + + def copy(self): + """Make a copy of self""" + + aCopy = LpSolver.copy(self) + aCopy.path = self.path + aCopy.keepFiles = self.keepFiles + aCopy.tmpDir = self.tmpDir + return aCopy + + def setTmpDir(self): + """Set the tmpDir attribute to a reasonnable location for a temporary + directory""" + if os.name != "nt": + # On unix use /tmp by default + self.tmpDir = os.environ.get("TMPDIR", "/tmp") + self.tmpDir = os.environ.get("TMP", self.tmpDir) + else: + # On Windows use the current directory + self.tmpDir = os.environ.get("TMPDIR", "") + self.tmpDir = os.environ.get("TMP", self.tmpDir) + self.tmpDir = os.environ.get("TEMP", self.tmpDir) + if not os.path.isdir(self.tmpDir): + self.tmpDir = "" + elif not os.access(self.tmpDir, os.F_OK + os.W_OK): + self.tmpDir = "" + + def create_tmp_files(self, name, *args): + if self.keepFiles: + prefix = name + else: + prefix = os.path.join(self.tmpDir, uuid4().hex) + return (f"{prefix}-pulp.{n}" for n in args) + + def silent_remove(self, file: Union[str, bytes, os.PathLike]) -> None: + try: + os.remove(file) + except FileNotFoundError: + pass + + def delete_tmp_files(self, *args): + if self.keepFiles: + return + for file in args: + self.silent_remove(file) + + def defaultPath(self): + raise NotImplementedError + + @staticmethod + def executableExtension(name): + if os.name != "nt": + return name + else: + return name + ".exe" + + @staticmethod + def executable(command): + """Checks that the solver command is executable, + And returns the actual path to it.""" + return shutil.which(command) + + +def ctypesArrayFill(myList, type=ctypes.c_double): + """ + Creates a c array with ctypes from a python list + type is the type of the c array + """ + ctype = type * len(myList) + cList = ctype() + for i, elem in enumerate(myList): + cList[i] = elem + return cList diff --git a/utils/pulp/apis/cplex_api.py b/utils/pulp/apis/cplex_api.py new file mode 100644 index 0000000..65ba3dd --- /dev/null +++ b/utils/pulp/apis/cplex_api.py @@ -0,0 +1,579 @@ +from .core import LpSolver_CMD, LpSolver, subprocess, PulpSolverError, clock, log +from .. import constants +import os +import warnings + + +class CPLEX_CMD(LpSolver_CMD): + """The CPLEX LP solver""" + + name = "CPLEX_CMD" + + def __init__( + self, + timelimit=None, + mip=True, + msg=True, + timeLimit=None, + gapRel=None, + gapAbs=None, + options=None, + warmStart=False, + keepFiles=False, + path=None, + threads=None, + logPath=None, + maxMemory=None, + maxNodes=None, + mip_start=False, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param int threads: sets the maximum number of threads + :param list options: list of additional options to pass to solver + :param bool warmStart: if True, the solver will use the current value of variables as a start + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + :param str logPath: path to the log file + :param float maxMemory: max memory to use during the solving. Stops the solving when reached. + :param int maxNodes: max number of nodes during branching. Stops the solving when reached. + :param bool mip_start: deprecated for warmStart + :param float timelimit: deprecated for timeLimit + """ + if timelimit is not None: + warnings.warn("Parameter timelimit is being depreciated for timeLimit") + if timeLimit is not None: + warnings.warn( + "Parameter timeLimit and timelimit passed, using timeLimit " + ) + else: + timeLimit = timelimit + if mip_start: + warnings.warn("Parameter mip_start is being depreciated for warmStart") + if warmStart: + warnings.warn( + "Parameter mipStart and mip_start passed, using warmStart" + ) + else: + warmStart = mip_start + LpSolver_CMD.__init__( + self, + gapRel=gapRel, + mip=mip, + msg=msg, + timeLimit=timeLimit, + options=options, + maxMemory=maxMemory, + maxNodes=maxNodes, + warmStart=warmStart, + path=path, + keepFiles=keepFiles, + threads=threads, + gapAbs=gapAbs, + logPath=logPath, + ) + + def defaultPath(self): + return self.executableExtension("cplex") + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + tmpLp, tmpSol, tmpMst = self.create_tmp_files(lp.name, "lp", "sol", "mst") + vs = lp.writeLP(tmpLp, writeSOS=1) + try: + os.remove(tmpSol) + except: + pass + if not self.msg: + cplex = subprocess.Popen( + self.path, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + else: + cplex = subprocess.Popen(self.path, stdin=subprocess.PIPE) + cplex_cmds = "read " + tmpLp + "\n" + if self.optionsDict.get("warmStart", False): + self.writesol(filename=tmpMst, vs=vs) + cplex_cmds += "read " + tmpMst + "\n" + cplex_cmds += "set advance 1\n" + + if self.timeLimit is not None: + cplex_cmds += "set timelimit " + str(self.timeLimit) + "\n" + options = self.options + self.getOptions() + for option in options: + cplex_cmds += option + "\n" + if lp.isMIP(): + if self.mip: + cplex_cmds += "mipopt\n" + cplex_cmds += "change problem fixed\n" + else: + cplex_cmds += "change problem lp\n" + cplex_cmds += "optimize\n" + cplex_cmds += "write " + tmpSol + "\n" + cplex_cmds += "quit\n" + cplex_cmds = cplex_cmds.encode("UTF-8") + cplex.communicate(cplex_cmds) + if cplex.returncode != 0: + raise PulpSolverError("PuLP: Error while trying to execute " + self.path) + if not os.path.exists(tmpSol): + status = constants.LpStatusInfeasible + values = reducedCosts = shadowPrices = slacks = solStatus = None + else: + ( + status, + values, + reducedCosts, + shadowPrices, + slacks, + solStatus, + ) = self.readsol(tmpSol) + self.delete_tmp_files(tmpLp, tmpMst, tmpSol) + if self.optionsDict.get("logPath") != "cplex.log": + self.delete_tmp_files("cplex.log") + if status != constants.LpStatusInfeasible: + lp.assignVarsVals(values) + lp.assignVarsDj(reducedCosts) + lp.assignConsPi(shadowPrices) + lp.assignConsSlack(slacks) + lp.assignStatus(status, solStatus) + return status + + def getOptions(self): + # CPLEX parameters: https://www.ibm.com/support/knowledgecenter/en/SSSA5P_12.6.0/ilog.odms.cplex.help/CPLEX/GettingStarted/topics/tutorials/InteractiveOptimizer/settingParams.html + # CPLEX status: https://www.ibm.com/support/knowledgecenter/en/SSSA5P_12.10.0/ilog.odms.cplex.help/refcallablelibrary/macros/Solution_status_codes.html + params_eq = dict( + logPath="set logFile {}", + gapRel="set mip tolerances mipgap {}", + gapAbs="set mip tolerances absmipgap {}", + maxMemory="set mip limits treememory {}", + threads="set threads {}", + maxNodes="set mip limits nodes {}", + ) + return [ + v.format(self.optionsDict[k]) + for k, v in params_eq.items() + if k in self.optionsDict and self.optionsDict[k] is not None + ] + + def readsol(self, filename): + """Read a CPLEX solution file""" + # CPLEX solution codes: http://www-eio.upc.es/lceio/manuals/cplex-11/html/overviewcplex/statuscodes.html + try: + import xml.etree.ElementTree as et + except ImportError: + import elementtree.ElementTree as et + solutionXML = et.parse(filename).getroot() + solutionheader = solutionXML.find("header") + statusString = solutionheader.get("solutionStatusString") + statusValue = solutionheader.get("solutionStatusValue") + cplexStatus = { + "1": constants.LpStatusOptimal, # optimal + "101": constants.LpStatusOptimal, # mip optimal + "102": constants.LpStatusOptimal, # mip optimal tolerance + "104": constants.LpStatusOptimal, # max solution limit + "105": constants.LpStatusOptimal, # node limit feasible + "107": constants.LpStatusOptimal, # time lim feasible + "109": constants.LpStatusOptimal, # fail but feasible + "113": constants.LpStatusOptimal, # abort feasible + } + if statusValue not in cplexStatus: + raise PulpSolverError( + "Unknown status returned by CPLEX: \ncode: '{}', string: '{}'".format( + statusValue, statusString + ) + ) + status = cplexStatus[statusValue] + # we check for integer feasible status to differentiate from optimal in solution status + cplexSolStatus = { + "104": constants.LpSolutionIntegerFeasible, # max solution limit + "105": constants.LpSolutionIntegerFeasible, # node limit feasible + "107": constants.LpSolutionIntegerFeasible, # time lim feasible + "109": constants.LpSolutionIntegerFeasible, # fail but feasible + "111": constants.LpSolutionIntegerFeasible, # memory limit feasible + "113": constants.LpSolutionIntegerFeasible, # abort feasible + } + solStatus = cplexSolStatus.get(statusValue) + shadowPrices = {} + slacks = {} + constraints = solutionXML.find("linearConstraints") + for constraint in constraints: + name = constraint.get("name") + slack = constraint.get("slack") + shadowPrice = constraint.get("dual") + try: + # See issue #508 + shadowPrices[name] = float(shadowPrice) + except TypeError: + shadowPrices[name] = None + slacks[name] = float(slack) + + values = {} + reducedCosts = {} + for variable in solutionXML.find("variables"): + name = variable.get("name") + value = variable.get("value") + values[name] = float(value) + reducedCost = variable.get("reducedCost") + try: + # See issue #508 + reducedCosts[name] = float(reducedCost) + except TypeError: + reducedCosts[name] = None + + return status, values, reducedCosts, shadowPrices, slacks, solStatus + + def writesol(self, filename, vs): + """Writes a CPLEX solution file""" + try: + import xml.etree.ElementTree as et + except ImportError: + import elementtree.ElementTree as et + root = et.Element("CPLEXSolution", version="1.2") + attrib_head = dict() + attrib_quality = dict() + et.SubElement(root, "header", attrib=attrib_head) + et.SubElement(root, "header", attrib=attrib_quality) + variables = et.SubElement(root, "variables") + + values = [(v.name, v.value()) for v in vs if v.value() is not None] + for index, (name, value) in enumerate(values): + attrib_vars = dict(name=name, value=str(value), index=str(index)) + et.SubElement(variables, "variable", attrib=attrib_vars) + mst = et.ElementTree(root) + mst.write(filename, encoding="utf-8", xml_declaration=True) + + return True + + +class CPLEX_PY(LpSolver): + """ + The CPLEX LP/MIP solver (via a Python Binding) + + This solver wraps the python api of cplex. + It has been tested against cplex 12.3. + For api functions that have not been wrapped in this solver please use + the base cplex classes + """ + + name = "CPLEX_PY" + try: + global cplex + import cplex + except Exception as e: + err = e + """The CPLEX LP/MIP solver from python. Something went wrong!!!!""" + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + raise PulpSolverError(f"CPLEX_PY: Not Available:\n{self.err}") + + else: + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + gapRel=None, + warmStart=False, + logPath=None, + epgap=None, + logfilename=None, + threads=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param bool warmStart: if True, the solver will use the current value of variables as a start + :param str logPath: path to the log file + :param float epgap: deprecated for gapRel + :param str logfilename: deprecated for logPath + :param int threads: number of threads to be used by CPLEX to solve a problem (default None uses all available) + """ + if epgap is not None: + warnings.warn("Parameter epgap is being depreciated for gapRel") + if gapRel is not None: + warnings.warn("Parameter gapRel and epgap passed, using gapRel") + else: + gapRel = epgap + if logfilename is not None: + warnings.warn("Parameter logfilename is being depreciated for logPath") + if logPath is not None: + warnings.warn( + "Parameter logPath and logfilename passed, using logPath" + ) + else: + logPath = logfilename + + LpSolver.__init__( + self, + gapRel=gapRel, + mip=mip, + msg=msg, + timeLimit=timeLimit, + warmStart=warmStart, + logPath=logPath, + threads=threads, + ) + + def available(self): + """True if the solver is available""" + return True + + def actualSolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + creates a cplex model, variables and constraints and attaches + them to the lp model which it then solves + """ + self.buildSolverModel(lp) + # set the initial solution + log.debug("Solve the Model using cplex") + self.callSolver(lp) + # get the solution information + solutionStatus = self.findSolutionValues(lp) + for var in lp._variables: + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus + + def buildSolverModel(self, lp): + """ + Takes the pulp lp model and translates it into a cplex model + """ + model_variables = lp.variables() + self.n2v = {var.name: var for var in model_variables} + if len(self.n2v) != len(model_variables): + raise PulpSolverError( + "Variables must have unique names for cplex solver" + ) + log.debug("create the cplex model") + self.solverModel = lp.solverModel = cplex.Cplex() + log.debug("set the name of the problem") + if not self.mip: + self.solverModel.set_problem_name(lp.name) + log.debug("set the sense of the problem") + if lp.sense == constants.LpMaximize: + lp.solverModel.objective.set_sense( + lp.solverModel.objective.sense.maximize + ) + obj = [float(lp.objective.get(var, 0.0)) for var in model_variables] + + def cplex_var_lb(var): + if var.lowBound is not None: + return float(var.lowBound) + else: + return -cplex.infinity + + lb = [cplex_var_lb(var) for var in model_variables] + + def cplex_var_ub(var): + if var.upBound is not None: + return float(var.upBound) + else: + return cplex.infinity + + ub = [cplex_var_ub(var) for var in model_variables] + colnames = [var.name for var in model_variables] + + def cplex_var_types(var): + if var.cat == constants.LpInteger: + return "I" + else: + return "C" + + ctype = [cplex_var_types(var) for var in model_variables] + ctype = "".join(ctype) + lp.solverModel.variables.add( + obj=obj, lb=lb, ub=ub, types=ctype, names=colnames + ) + rows = [] + senses = [] + rhs = [] + rownames = [] + for name, constraint in lp.constraints.items(): + # build the expression + expr = [(var.name, float(coeff)) for var, coeff in constraint.items()] + if not expr: + # if the constraint is empty + rows.append(([], [])) + else: + rows.append(list(zip(*expr))) + if constraint.sense == constants.LpConstraintLE: + senses.append("L") + elif constraint.sense == constants.LpConstraintGE: + senses.append("G") + elif constraint.sense == constants.LpConstraintEQ: + senses.append("E") + else: + raise PulpSolverError("Detected an invalid constraint type") + rownames.append(name) + rhs.append(float(-constraint.constant)) + lp.solverModel.linear_constraints.add( + lin_expr=rows, senses=senses, rhs=rhs, names=rownames + ) + log.debug("set the type of the problem") + if not self.mip: + self.solverModel.set_problem_type(cplex.Cplex.problem_type.LP) + log.debug("set the logging") + if not self.msg: + self.setlogfile(None) + logPath = self.optionsDict.get("logPath") + if logPath is not None: + if self.msg: + warnings.warn( + "`logPath` argument replaces `msg=1`. The output will be redirected to the log file." + ) + self.setlogfile(open(logPath, "w")) + gapRel = self.optionsDict.get("gapRel") + if gapRel is not None: + self.changeEpgap(gapRel) + if self.timeLimit is not None: + self.setTimeLimit(self.timeLimit) + self.setThreads(self.optionsDict.get("threads", None)) + if self.optionsDict.get("warmStart", False): + # We assume "auto" for the effort_level + effort = self.solverModel.MIP_starts.effort_level.auto + start = [ + (k, v.value()) for k, v in self.n2v.items() if v.value() is not None + ] + if not start: + warnings.warn("No variable with value found: mipStart aborted") + return + ind, val = zip(*start) + self.solverModel.MIP_starts.add( + cplex.SparsePair(ind=ind, val=val), effort, "1" + ) + + def setlogfile(self, fileobj): + """ + sets the logfile for cplex output + """ + self.solverModel.set_error_stream(fileobj) + self.solverModel.set_log_stream(fileobj) + self.solverModel.set_warning_stream(fileobj) + self.solverModel.set_results_stream(fileobj) + + def setThreads(self, threads=None): + """ + Change cplex thread count used (None is default which uses all available resources) + """ + self.solverModel.parameters.threads.set(threads or 0) + + def changeEpgap(self, epgap=10**-4): + """ + Change cplex solver integer bound gap tolerence + """ + self.solverModel.parameters.mip.tolerances.mipgap.set(epgap) + + def setTimeLimit(self, timeLimit=0.0): + """ + Make cplex limit the time it takes --added CBM 8/28/09 + """ + self.solverModel.parameters.timelimit.set(timeLimit) + + def callSolver(self, isMIP): + """Solves the problem with cplex""" + # solve the problem + self.solveTime = -clock() + self.solverModel.solve() + self.solveTime += clock() + + def findSolutionValues(self, lp): + CplexLpStatus = { + lp.solverModel.solution.status.MIP_optimal: constants.LpStatusOptimal, + lp.solverModel.solution.status.optimal: constants.LpStatusOptimal, + lp.solverModel.solution.status.optimal_tolerance: constants.LpStatusOptimal, + lp.solverModel.solution.status.infeasible: constants.LpStatusInfeasible, + lp.solverModel.solution.status.infeasible_or_unbounded: constants.LpStatusInfeasible, + lp.solverModel.solution.status.MIP_infeasible: constants.LpStatusInfeasible, + lp.solverModel.solution.status.MIP_infeasible_or_unbounded: constants.LpStatusInfeasible, + lp.solverModel.solution.status.unbounded: constants.LpStatusUnbounded, + lp.solverModel.solution.status.MIP_unbounded: constants.LpStatusUnbounded, + lp.solverModel.solution.status.abort_dual_obj_limit: constants.LpStatusNotSolved, + lp.solverModel.solution.status.abort_iteration_limit: constants.LpStatusNotSolved, + lp.solverModel.solution.status.abort_obj_limit: constants.LpStatusNotSolved, + lp.solverModel.solution.status.abort_relaxed: constants.LpStatusNotSolved, + lp.solverModel.solution.status.abort_time_limit: constants.LpStatusNotSolved, + lp.solverModel.solution.status.abort_user: constants.LpStatusNotSolved, + lp.solverModel.solution.status.MIP_abort_feasible: constants.LpStatusOptimal, + lp.solverModel.solution.status.MIP_time_limit_feasible: constants.LpStatusOptimal, + lp.solverModel.solution.status.MIP_time_limit_infeasible: constants.LpStatusInfeasible, + } + lp.cplex_status = lp.solverModel.solution.get_status() + status = CplexLpStatus.get(lp.cplex_status, constants.LpStatusUndefined) + CplexSolStatus = { + lp.solverModel.solution.status.MIP_time_limit_feasible: constants.LpSolutionIntegerFeasible, + lp.solverModel.solution.status.MIP_abort_feasible: constants.LpSolutionIntegerFeasible, + lp.solverModel.solution.status.MIP_feasible: constants.LpSolutionIntegerFeasible, + } + # TODO: I did not find the following status: CPXMIP_NODE_LIM_FEAS, CPXMIP_MEM_LIM_FEAS + sol_status = CplexSolStatus.get(lp.cplex_status) + lp.assignStatus(status, sol_status) + var_names = [var.name for var in lp._variables] + con_names = [con for con in lp.constraints] + try: + objectiveValue = lp.solverModel.solution.get_objective_value() + variablevalues = dict( + zip(var_names, lp.solverModel.solution.get_values(var_names)) + ) + lp.assignVarsVals(variablevalues) + constraintslackvalues = dict( + zip(con_names, lp.solverModel.solution.get_linear_slacks(con_names)) + ) + lp.assignConsSlack(constraintslackvalues) + if lp.solverModel.get_problem_type() == cplex.Cplex.problem_type.LP: + variabledjvalues = dict( + zip( + var_names, + lp.solverModel.solution.get_reduced_costs(var_names), + ) + ) + lp.assignVarsDj(variabledjvalues) + constraintpivalues = dict( + zip( + con_names, + lp.solverModel.solution.get_dual_values(con_names), + ) + ) + lp.assignConsPi(constraintpivalues) + except cplex.exceptions.CplexSolverError: + # raises this error when there is no solution + pass + # put pi and slack variables against the constraints + # TODO: clear up the name of self.n2c + if self.msg: + print("Cplex status=", lp.cplex_status) + lp.resolveOK = True + for var in lp._variables: + var.isModified = False + return status + + def actualResolve(self, lp, **kwargs): + """ + looks at which variables have been modified and changes them + """ + raise NotImplementedError("Resolves in CPLEX_PY not yet implemented") + + +CPLEX = CPLEX_CMD diff --git a/utils/pulp/apis/glpk_api.py b/utils/pulp/apis/glpk_api.py new file mode 100644 index 0000000..27e711d --- /dev/null +++ b/utils/pulp/apis/glpk_api.py @@ -0,0 +1,409 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +from .core import LpSolver_CMD, LpSolver, subprocess, PulpSolverError, clock +from .core import glpk_path, operating_system, log +import os +from .. import constants + + +class GLPK_CMD(LpSolver_CMD): + """The GLPK LP solver""" + + name = "GLPK_CMD" + + def __init__( + self, + path=None, + keepFiles=False, + mip=True, + msg=True, + options=None, + timeLimit=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param list options: list of additional options to pass to solver + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + """ + LpSolver_CMD.__init__( + self, + mip=mip, + msg=msg, + timeLimit=timeLimit, + options=options, + path=path, + keepFiles=keepFiles, + ) + + def defaultPath(self): + return self.executableExtension(glpk_path) + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + tmpLp, tmpSol = self.create_tmp_files(lp.name, "lp", "sol") + lp.writeLP(tmpLp, writeSOS=0) + proc = ["glpsol", "--cpxlp", tmpLp, "-o", tmpSol] + if self.timeLimit: + proc.extend(["--tmlim", str(self.timeLimit)]) + if not self.mip: + proc.append("--nomip") + proc.extend(self.options) + + self.solution_time = clock() + if not self.msg: + proc[0] = self.path + pipe = open(os.devnull, "w") + if operating_system == "win": + # Prevent flashing windows if used from a GUI application + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + rc = subprocess.call( + proc, stdout=pipe, stderr=pipe, startupinfo=startupinfo + ) + else: + rc = subprocess.call(proc, stdout=pipe, stderr=pipe) + if rc: + raise PulpSolverError( + "PuLP: Error while trying to execute " + self.path + ) + pipe.close() + else: + if os.name != "nt": + rc = os.spawnvp(os.P_WAIT, self.path, proc) + else: + rc = os.spawnv(os.P_WAIT, self.executable(self.path), proc) + if rc == 127: + raise PulpSolverError( + "PuLP: Error while trying to execute " + self.path + ) + self.solution_time += clock() + + if not os.path.exists(tmpSol): + raise PulpSolverError("PuLP: Error while executing " + self.path) + status, values = self.readsol(tmpSol) + lp.assignVarsVals(values) + lp.assignStatus(status) + self.delete_tmp_files(tmpLp, tmpSol) + return status + + def readsol(self, filename): + """Read a GLPK solution file""" + with open(filename) as f: + f.readline() + rows = int(f.readline().split()[1]) + cols = int(f.readline().split()[1]) + f.readline() + statusString = f.readline()[12:-1] + glpkStatus = { + "INTEGER OPTIMAL": constants.LpStatusOptimal, + "INTEGER NON-OPTIMAL": constants.LpStatusOptimal, + "OPTIMAL": constants.LpStatusOptimal, + "INFEASIBLE (FINAL)": constants.LpStatusInfeasible, + "INTEGER UNDEFINED": constants.LpStatusUndefined, + "UNBOUNDED": constants.LpStatusUnbounded, + "UNDEFINED": constants.LpStatusUndefined, + "INTEGER EMPTY": constants.LpStatusInfeasible, + } + if statusString not in glpkStatus: + raise PulpSolverError("Unknown status returned by GLPK") + status = glpkStatus[statusString] + isInteger = statusString in [ + "INTEGER NON-OPTIMAL", + "INTEGER OPTIMAL", + "INTEGER UNDEFINED", + "INTEGER EMPTY", + ] + values = {} + for i in range(4): + f.readline() + for i in range(rows): + line = f.readline().split() + if len(line) == 2: + f.readline() + for i in range(3): + f.readline() + for i in range(cols): + line = f.readline().split() + name = line[1] + if len(line) == 2: + line = [0, 0] + f.readline().split() + if isInteger: + if line[2] == "*": + value = int(float(line[3])) + else: + value = float(line[2]) + else: + value = float(line[3]) + values[name] = value + return status, values + + +GLPK = GLPK_CMD + +# get the glpk name in global scope +glpk = None + + +class PYGLPK(LpSolver): + """ + The glpk LP/MIP solver (via its python interface) + + Copyright Christophe-Marie Duquesne 2012 + + The glpk variables are available (after a solve) in var.solverVar + The glpk constraints are available in constraint.solverConstraint + The Model is in prob.solverModel + """ + + name = "PYGLPK" + + try: + # import the model into the global scope + global glpk + import glpk.glpkpi as glpk + except: + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp, callback=None): + """Solve a well formulated lp problem""" + raise PulpSolverError("GLPK: Not Available") + + else: + + def __init__( + self, mip=True, msg=True, timeLimit=None, epgap=None, **solverParams + ): + """ + Initializes the glpk solver. + + @param mip: if False the solver will solve a MIP as an LP + @param msg: displays information from the solver to stdout + @param timeLimit: not handled + @param epgap: not handled + @param solverParams: not handled + """ + LpSolver.__init__(self, mip, msg) + if not self.msg: + glpk.glp_term_out(glpk.GLP_OFF) + + def findSolutionValues(self, lp): + prob = lp.solverModel + if self.mip and self.hasMIPConstraints(lp.solverModel): + solutionStatus = glpk.glp_mip_status(prob) + else: + solutionStatus = glpk.glp_get_status(prob) + glpkLpStatus = { + glpk.GLP_OPT: constants.LpStatusOptimal, + glpk.GLP_UNDEF: constants.LpStatusUndefined, + glpk.GLP_FEAS: constants.LpStatusOptimal, + glpk.GLP_INFEAS: constants.LpStatusInfeasible, + glpk.GLP_NOFEAS: constants.LpStatusInfeasible, + glpk.GLP_UNBND: constants.LpStatusUnbounded, + } + # populate pulp solution values + for var in lp.variables(): + if self.mip and self.hasMIPConstraints(lp.solverModel): + var.varValue = glpk.glp_mip_col_val(prob, var.glpk_index) + else: + var.varValue = glpk.glp_get_col_prim(prob, var.glpk_index) + var.dj = glpk.glp_get_col_dual(prob, var.glpk_index) + # put pi and slack variables against the constraints + for constr in lp.constraints.values(): + if self.mip and self.hasMIPConstraints(lp.solverModel): + row_val = glpk.glp_mip_row_val(prob, constr.glpk_index) + else: + row_val = glpk.glp_get_row_prim(prob, constr.glpk_index) + constr.slack = -constr.constant - row_val + constr.pi = glpk.glp_get_row_dual(prob, constr.glpk_index) + lp.resolveOK = True + for var in lp.variables(): + var.isModified = False + status = glpkLpStatus.get(solutionStatus, constants.LpStatusUndefined) + lp.assignStatus(status) + return status + + def available(self): + """True if the solver is available""" + return True + + def hasMIPConstraints(self, solverModel): + return ( + glpk.glp_get_num_int(solverModel) > 0 + or glpk.glp_get_num_bin(solverModel) > 0 + ) + + def callSolver(self, lp, callback=None): + """Solves the problem with glpk""" + self.solveTime = -clock() + glpk.glp_adv_basis(lp.solverModel, 0) + glpk.glp_simplex(lp.solverModel, None) + if self.mip and self.hasMIPConstraints(lp.solverModel): + status = glpk.glp_get_status(lp.solverModel) + if status in (glpk.GLP_OPT, glpk.GLP_UNDEF, glpk.GLP_FEAS): + glpk.glp_intopt(lp.solverModel, None) + self.solveTime += clock() + + def buildSolverModel(self, lp): + """ + Takes the pulp lp model and translates it into a glpk model + """ + log.debug("create the glpk model") + prob = glpk.glp_create_prob() + glpk.glp_set_prob_name(prob, lp.name) + log.debug("set the sense of the problem") + if lp.sense == constants.LpMaximize: + glpk.glp_set_obj_dir(prob, glpk.GLP_MAX) + log.debug("add the constraints to the problem") + glpk.glp_add_rows(prob, len(list(lp.constraints.keys()))) + for i, v in enumerate(lp.constraints.items(), start=1): + name, constraint = v + glpk.glp_set_row_name(prob, i, name) + if constraint.sense == constants.LpConstraintLE: + glpk.glp_set_row_bnds( + prob, i, glpk.GLP_UP, 0.0, -constraint.constant + ) + elif constraint.sense == constants.LpConstraintGE: + glpk.glp_set_row_bnds( + prob, i, glpk.GLP_LO, -constraint.constant, 0.0 + ) + elif constraint.sense == constants.LpConstraintEQ: + glpk.glp_set_row_bnds( + prob, i, glpk.GLP_FX, -constraint.constant, -constraint.constant + ) + else: + raise PulpSolverError("Detected an invalid constraint type") + constraint.glpk_index = i + log.debug("add the variables to the problem") + glpk.glp_add_cols(prob, len(lp.variables())) + for j, var in enumerate(lp.variables(), start=1): + glpk.glp_set_col_name(prob, j, var.name) + lb = 0.0 + ub = 0.0 + t = glpk.GLP_FR + if not var.lowBound is None: + lb = var.lowBound + t = glpk.GLP_LO + if not var.upBound is None: + ub = var.upBound + t = glpk.GLP_UP + if not var.upBound is None and not var.lowBound is None: + if ub == lb: + t = glpk.GLP_FX + else: + t = glpk.GLP_DB + glpk.glp_set_col_bnds(prob, j, t, lb, ub) + if var.cat == constants.LpInteger: + glpk.glp_set_col_kind(prob, j, glpk.GLP_IV) + assert glpk.glp_get_col_kind(prob, j) == glpk.GLP_IV + var.glpk_index = j + log.debug("set the objective function") + for var in lp.variables(): + value = lp.objective.get(var) + if value: + glpk.glp_set_obj_coef(prob, var.glpk_index, value) + log.debug("set the problem matrix") + for constraint in lp.constraints.values(): + l = len(list(constraint.items())) + ind = glpk.intArray(l + 1) + val = glpk.doubleArray(l + 1) + for j, v in enumerate(constraint.items(), start=1): + var, value = v + ind[j] = var.glpk_index + val[j] = value + glpk.glp_set_mat_row(prob, constraint.glpk_index, l, ind, val) + lp.solverModel = prob + # glpk.glp_write_lp(prob, None, "glpk.lp") + + def actualSolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + creates a glpk model, variables and constraints and attaches + them to the lp model which it then solves + """ + self.buildSolverModel(lp) + # set the initial solution + log.debug("Solve the Model using glpk") + self.callSolver(lp, callback=callback) + # get the solution information + solutionStatus = self.findSolutionValues(lp) + for var in lp.variables(): + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus + + def actualResolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + uses the old solver and modifies the rhs of the modified + constraints + """ + prob = lp.solverModel + log.debug("Resolve the Model using glpk") + for constraint in lp.constraints.values(): + i = constraint.glpk_index + if constraint.modified: + if constraint.sense == constants.LpConstraintLE: + glpk.glp_set_row_bnds( + prob, i, glpk.GLP_UP, 0.0, -constraint.constant + ) + elif constraint.sense == constants.LpConstraintGE: + glpk.glp_set_row_bnds( + prob, i, glpk.GLP_LO, -constraint.constant, 0.0 + ) + elif constraint.sense == constants.LpConstraintEQ: + glpk.glp_set_row_bnds( + prob, + i, + glpk.GLP_FX, + -constraint.constant, + -constraint.constant, + ) + else: + raise PulpSolverError("Detected an invalid constraint type") + self.callSolver(lp, callback=callback) + # get the solution information + solutionStatus = self.findSolutionValues(lp) + for var in lp.variables(): + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus diff --git a/utils/pulp/apis/gurobi_api.py b/utils/pulp/apis/gurobi_api.py new file mode 100644 index 0000000..05332da --- /dev/null +++ b/utils/pulp/apis/gurobi_api.py @@ -0,0 +1,566 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + + +from .core import LpSolver_CMD, LpSolver, subprocess, PulpSolverError, clock, log +from .core import gurobi_path +import os +import sys +from .. import constants +import warnings + +# to import the gurobipy name into the module scope +gp = None + + +class GUROBI(LpSolver): + """ + The Gurobi LP/MIP solver (via its python interface) + + The Gurobi variables are available (after a solve) in var.solverVar + Constraints in constraint.solverConstraint + and the Model is in prob.solverModel + """ + + name = "GUROBI" + env = None + + try: + sys.path.append(gurobi_path) + # to import the name into the module scope + global gp + import gurobipy as gp + except: # FIXME: Bug because gurobi returns + # a gurobi exception on failed imports + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp, callback=None): + """Solve a well formulated lp problem""" + raise PulpSolverError("GUROBI: Not Available") + + else: + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + epgap=None, + gapRel=None, + warmStart=False, + logPath=None, + env=None, + envOptions=None, + manageEnv=False, + **solverParams, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param bool warmStart: if True, the solver will use the current value of variables as a start + :param str logPath: path to the log file + :param float epgap: deprecated for gapRel + :param gp.Env env: Gurobi environment to use. Default None. + :param dict envOptions: environment options. + :param bool manageEnv: if False, assume the environment is handled by the user. + + + If ``manageEnv`` is set to True, the ``GUROBI`` object creates a + local Gurobi environment and manages all associated Gurobi + resources. Importantly, this enables Gurobi licenses to be freed + and connections terminated when the ``.close()`` function is called + (this function always disposes of the Gurobi model, and the + environment):: + + solver = GUROBI(manageEnv=True) + prob.solve(solver) + solver.close() # Must be called to free Gurobi resources. + # All Gurobi models and environments are freed + + ``manageEnv=True`` is required when setting license or connection + parameters. The ``envOptions`` argument is used to pass parameters + to the Gurobi environment. For example, to connect to a Gurobi + Cluster Manager:: + + options = { + "CSManager": "", + "CSAPIAccessID": "", + "CSAPISecret": "", + } + solver = GUROBI(manageEnv=True, envOptions=options) + solver.close() + # Compute server connection terminated + + Alternatively, one can also pass a ``gp.Env`` object. In this case, + to be safe, one should still call ``.close()`` to dispose of the + model:: + + with gp.Env(params=options) as env: + # Pass environment as a parameter + solver = GUROBI(env=env) + prob.solve(solver) + solver.close() + # Still call `close` as this disposes the model which is required to correctly free env + + If ``manageEnv`` is set to False (the default), the ``GUROBI`` + object uses the global default Gurobi environment which will be + freed once the object is deleted. In this case, one can still call + ``.close()`` to dispose of the model:: + + solver = GUROBI() + prob.solve(solver) + # The global default environment and model remain active + solver.close() + # Only the global default environment remains active + """ + self.env = env + self.env_options = envOptions if envOptions else {} + self.manage_env = False if self.env is not None else manageEnv + self.solver_params = solverParams + + self.model = None + self.init_gurobi = False # whether env and model have been initialised + + if epgap is not None: + warnings.warn("Parameter epgap is being depreciated for gapRel") + if gapRel is not None: + warnings.warn("Parameter gapRel and epgap passed, using gapRel") + else: + gapRel = epgap + + LpSolver.__init__( + self, + mip=mip, + msg=msg, + timeLimit=timeLimit, + gapRel=gapRel, + logPath=logPath, + warmStart=warmStart, + ) + + # set the output of gurobi + if not self.msg: + if self.manage_env: + self.env_options["OutputFlag"] = 0 + else: + self.env_options["OutputFlag"] = 0 + self.solver_params["OutputFlag"] = 0 + + def __del__(self): + self.close() + + def close(self): + """ + Must be called when internal Gurobi model and/or environment + requires disposing. The environment (default or otherwise) will be + disposed only if ``manageEnv`` is set to True. + """ + if not self.init_gurobi: + return + self.model.dispose() + if self.manage_env: + self.env.dispose() + + def findSolutionValues(self, lp): + model = lp.solverModel + solutionStatus = model.Status + GRB = gp.GRB + # TODO: check status for Integer Feasible + gurobiLpStatus = { + GRB.OPTIMAL: constants.LpStatusOptimal, + GRB.INFEASIBLE: constants.LpStatusInfeasible, + GRB.INF_OR_UNBD: constants.LpStatusInfeasible, + GRB.UNBOUNDED: constants.LpStatusUnbounded, + GRB.ITERATION_LIMIT: constants.LpStatusNotSolved, + GRB.NODE_LIMIT: constants.LpStatusNotSolved, + GRB.TIME_LIMIT: constants.LpStatusNotSolved, + GRB.SOLUTION_LIMIT: constants.LpStatusNotSolved, + GRB.INTERRUPTED: constants.LpStatusNotSolved, + GRB.NUMERIC: constants.LpStatusNotSolved, + } + if self.msg: + print("Gurobi status=", solutionStatus) + lp.resolveOK = True + for var in lp._variables: + var.isModified = False + status = gurobiLpStatus.get(solutionStatus, constants.LpStatusUndefined) + lp.assignStatus(status) + if model.SolCount >= 1: + # populate pulp solution values + for var, value in zip( + lp._variables, model.getAttr(GRB.Attr.X, model.getVars()) + ): + var.varValue = value + # populate pulp constraints slack + for constr, value in zip( + lp.constraints.values(), + model.getAttr(GRB.Attr.Slack, model.getConstrs()), + ): + constr.slack = value + # put pi and slack variables against the constraints + if not model.IsMIP: + for var, value in zip( + lp._variables, model.getAttr(GRB.Attr.RC, model.getVars()) + ): + var.dj = value + + for constr, value in zip( + lp.constraints.values(), + model.getAttr(GRB.Attr.Pi, model.getConstrs()), + ): + constr.pi = value + return status + + def available(self): + """True if the solver is available""" + try: + with gp.Env(params=self.env_options): + pass + except gurobipy.GurobiError as e: + warnings.warn(f"GUROBI error: {e}.") + return False + return True + + def initGurobi(self): + if self.init_gurobi: + return + else: + self.init_gurobi = True + try: + if self.manage_env: + self.env = gp.Env(params=self.env_options) + self.model = gp.Model(env=self.env) + # Environment handled by user or default Env + else: + self.model = gp.Model(env=self.env) + # Set solver parameters + for param, value in self.solver_params.items(): + self.model.setParam(param, value) + except gp.GurobiError as e: + raise e + + def callSolver(self, lp, callback=None): + """Solves the problem with gurobi""" + # solve the problem + self.solveTime = -clock() + lp.solverModel.optimize(callback=callback) + self.solveTime += clock() + + def buildSolverModel(self, lp): + """ + Takes the pulp lp model and translates it into a gurobi model + """ + log.debug("create the gurobi model") + self.initGurobi() + self.model.ModelName = lp.name + lp.solverModel = self.model + log.debug("set the sense of the problem") + if lp.sense == constants.LpMaximize: + lp.solverModel.setAttr("ModelSense", -1) + if self.timeLimit: + lp.solverModel.setParam("TimeLimit", self.timeLimit) + gapRel = self.optionsDict.get("gapRel") + logPath = self.optionsDict.get("logPath") + if gapRel: + lp.solverModel.setParam("MIPGap", gapRel) + if logPath: + lp.solverModel.setParam("LogFile", logPath) + + log.debug("add the variables to the problem") + lp.solverModel.update() + nvars = lp.solverModel.NumVars + for var in lp.variables(): + lowBound = var.lowBound + if lowBound is None: + lowBound = -gp.GRB.INFINITY + upBound = var.upBound + if upBound is None: + upBound = gp.GRB.INFINITY + obj = lp.objective.get(var, 0.0) + varType = gp.GRB.CONTINUOUS + if var.cat == constants.LpInteger and self.mip: + varType = gp.GRB.INTEGER + # only add variable once, ow new variable will be created. + if not hasattr(var, "solverVar") or nvars == 0: + var.solverVar = lp.solverModel.addVar( + lowBound, upBound, vtype=varType, obj=obj, name=var.name + ) + if self.optionsDict.get("warmStart", False): + # Once lp.variables() has been used at least once in the building of the model. + # we can use the lp._variables with the cache. + for var in lp._variables: + if var.varValue is not None: + var.solverVar.start = var.varValue + + lp.solverModel.update() + log.debug("add the Constraints to the problem") + for name, constraint in lp.constraints.items(): + # build the expression + expr = gp.LinExpr( + list(constraint.values()), [v.solverVar for v in constraint.keys()] + ) + if constraint.sense == constants.LpConstraintLE: + constraint.solverConstraint = lp.solverModel.addConstr( + expr <= -constraint.constant, name=name + ) + elif constraint.sense == constants.LpConstraintGE: + constraint.solverConstraint = lp.solverModel.addConstr( + expr >= -constraint.constant, name=name + ) + elif constraint.sense == constants.LpConstraintEQ: + constraint.solverConstraint = lp.solverModel.addConstr( + expr == -constraint.constant, name=name + ) + else: + raise PulpSolverError("Detected an invalid constraint type") + lp.solverModel.update() + + def actualSolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + creates a gurobi model, variables and constraints and attaches + them to the lp model which it then solves + """ + self.buildSolverModel(lp) + # set the initial solution + log.debug("Solve the Model using gurobi") + self.callSolver(lp, callback=callback) + # get the solution information + solutionStatus = self.findSolutionValues(lp) + for var in lp._variables: + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus + + def actualResolve(self, lp, callback=None): + """ + Solve a well formulated lp problem + + uses the old solver and modifies the rhs of the modified constraints + """ + log.debug("Resolve the Model using gurobi") + for constraint in lp.constraints.values(): + if constraint.modified: + constraint.solverConstraint.setAttr( + gp.GRB.Attr.RHS, -constraint.constant + ) + lp.solverModel.update() + self.callSolver(lp, callback=callback) + # get the solution information + solutionStatus = self.findSolutionValues(lp) + for var in lp._variables: + var.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus + + +class GUROBI_CMD(LpSolver_CMD): + """The GUROBI_CMD solver""" + + name = "GUROBI_CMD" + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + gapRel=None, + gapAbs=None, + options=None, + warmStart=False, + keepFiles=False, + path=None, + threads=None, + logPath=None, + mip_start=False, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param int threads: sets the maximum number of threads + :param list options: list of additional options to pass to solver + :param bool warmStart: if True, the solver will use the current value of variables as a start + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + :param str logPath: path to the log file + :param bool mip_start: deprecated for warmStart + """ + if mip_start: + warnings.warn("Parameter mip_start is being depreciated for warmStart") + if warmStart: + warnings.warn( + "Parameter warmStart and mip_start passed, using warmStart" + ) + else: + warmStart = mip_start + LpSolver_CMD.__init__( + self, + gapRel=gapRel, + mip=mip, + msg=msg, + timeLimit=timeLimit, + options=options, + warmStart=warmStart, + path=path, + keepFiles=keepFiles, + threads=threads, + gapAbs=gapAbs, + logPath=logPath, + ) + + def defaultPath(self): + return self.executableExtension("gurobi_cl") + + def available(self): + """True if the solver is available""" + if not self.executable(self.path): + return False + # we execute gurobi once to check the return code. + # this is to test that the license is active + result = subprocess.Popen( + self.path, stdout=subprocess.PIPE, universal_newlines=True + ) + out, err = result.communicate() + if result.returncode == 0: + # normal execution + return True + # error: we display the gurobi message + warnings.warn(f"GUROBI error: {out}.") + return False + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + tmpLp, tmpSol, tmpMst = self.create_tmp_files(lp.name, "lp", "sol", "mst") + vs = lp.writeLP(tmpLp, writeSOS=1) + try: + os.remove(tmpSol) + except: + pass + cmd = self.path + options = self.options + self.getOptions() + if self.timeLimit is not None: + options.append(("TimeLimit", self.timeLimit)) + cmd += " " + " ".join([f"{key}={value}" for key, value in options]) + cmd += f" ResultFile={tmpSol}" + if self.optionsDict.get("warmStart", False): + self.writesol(filename=tmpMst, vs=vs) + cmd += f" InputFile={tmpMst}" + + if lp.isMIP(): + if not self.mip: + warnings.warn("GUROBI_CMD does not allow a problem to be relaxed") + cmd += f" {tmpLp}" + if self.msg: + pipe = None + else: + pipe = open(os.devnull, "w") + + return_code = subprocess.call(cmd.split(), stdout=pipe, stderr=pipe) + + # Close the pipe now if we used it. + if pipe is not None: + pipe.close() + + if return_code != 0: + raise PulpSolverError("PuLP: Error while trying to execute " + self.path) + if not os.path.exists(tmpSol): + # TODO: the status should be infeasible here, I think + status = constants.LpStatusNotSolved + values = reducedCosts = shadowPrices = slacks = None + else: + # TODO: the status should be infeasible here, I think + status, values, reducedCosts, shadowPrices, slacks = self.readsol(tmpSol) + self.delete_tmp_files(tmpLp, tmpMst, tmpSol, "gurobi.log") + if status != constants.LpStatusInfeasible: + lp.assignVarsVals(values) + lp.assignVarsDj(reducedCosts) + lp.assignConsPi(shadowPrices) + lp.assignConsSlack(slacks) + lp.assignStatus(status) + return status + + def readsol(self, filename): + """Read a Gurobi solution file""" + with open(filename) as my_file: + try: + next(my_file) # skip the objective value + except StopIteration: + # Empty file not solved + status = constants.LpStatusNotSolved + return status, {}, {}, {}, {} + # We have no idea what the status is assume optimal + # TODO: check status for Integer Feasible + status = constants.LpStatusOptimal + + shadowPrices = {} + slacks = {} + shadowPrices = {} + slacks = {} + values = {} + reducedCosts = {} + for line in my_file: + if line[0] != "#": # skip comments + name, value = line.split() + values[name] = float(value) + return status, values, reducedCosts, shadowPrices, slacks + + def writesol(self, filename, vs): + """Writes a GUROBI solution file""" + + values = [(v.name, v.value()) for v in vs if v.value() is not None] + rows = [] + for name, value in values: + rows.append(f"{name} {value}") + with open(filename, "w") as f: + f.write("\n".join(rows)) + return True + + def getOptions(self): + # GUROBI parameters: http://www.gurobi.com/documentation/7.5/refman/parameters.html#sec:Parameters + params_eq = dict( + logPath="LogFile", + gapRel="MIPGap", + gapAbs="MIPGapAbs", + threads="Threads", + ) + return [ + (v, self.optionsDict[k]) + for k, v in params_eq.items() + if k in self.optionsDict and self.optionsDict[k] is not None + ] diff --git a/utils/pulp/apis/highs_api.py b/utils/pulp/apis/highs_api.py new file mode 100644 index 0000000..2ecd11a --- /dev/null +++ b/utils/pulp/apis/highs_api.py @@ -0,0 +1,409 @@ +# PuLP : Python LP Modeler +# Version 2.4 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +# Modified by Sam Mathew (@samiit on Github) +# Users would need to install HiGHS on their machine and provide the path to the executable. Please look at this thread: https://github.com/ERGO-Code/HiGHS/issues/527#issuecomment-894852288 +# More instructions on: https://www.highs.dev + +from typing import List + +from .core import LpSolver, LpSolver_CMD, subprocess, PulpSolverError +import os, sys +from .. import constants + + +class HiGHS_CMD(LpSolver_CMD): + """The HiGHS_CMD solver""" + + name: str = "HiGHS_CMD" + + SOLUTION_STYLE: int = 0 + + def __init__( + self, + path=None, + keepFiles=False, + mip=True, + msg=True, + options=None, + timeLimit=None, + gapRel=None, + gapAbs=None, + threads=None, + logPath=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param list[str] options: list of additional options to pass to solver + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary (you can get binaries for your platform from https://github.com/JuliaBinaryWrappers/HiGHS_jll.jl/releases, or else compile from source - https://highs.dev) + :param int threads: sets the maximum number of threads + :param str logPath: path to the log file + """ + LpSolver_CMD.__init__( + self, + mip=mip, + msg=msg, + timeLimit=timeLimit, + gapRel=gapRel, + gapAbs=gapAbs, + options=options, + path=path, + keepFiles=keepFiles, + threads=threads, + logPath=logPath, + ) + + def defaultPath(self): + return self.executableExtension("highs") + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + lp.checkDuplicateVars() + + tmpMps, tmpSol, tmpOptions, tmpLog = self.create_tmp_files( + lp.name, "mps", "sol", "HiGHS", "HiGHS_log" + ) + lp.writeMPS(tmpMps, with_objsense=True) + + file_options: List[str] = [] + file_options.append(f"solution_file={tmpSol}") + file_options.append("write_solution_to_file=true") + file_options.append(f"write_solution_style={HiGHS_CMD.SOLUTION_STYLE}") + if not self.msg: + file_options.append("log_to_console=false") + if "threads" in self.optionsDict: + file_options.append(f"threads={self.optionsDict['threads']}") + if "gapRel" in self.optionsDict: + file_options.append(f"mip_rel_gap={self.optionsDict['gapRel']}") + if "gapAbs" in self.optionsDict: + file_options.append(f"mip_abs_gap={self.optionsDict['gapAbs']}") + if "logPath" in self.optionsDict: + highs_log_file = self.optionsDict["logPath"] + else: + highs_log_file = tmpLog + file_options.append(f"log_file={highs_log_file}") + + command: List[str] = [] + command.append(self.path) + command.append(tmpMps) + command.append(f"--options_file={tmpOptions}") + if self.timeLimit is not None: + command.append(f"--time_limit={self.timeLimit}") + if not self.mip: + command.append("--solver=simplex") + if "threads" in self.optionsDict: + command.append("--parallel=on") + + options = iter(self.options) + for option in options: + # assumption: all cli and file options require an argument which is provided after the equal sign (=) + if "=" not in option: + option += f"={next(options)}" + + # identify cli options by a leading dash (-) and treat other options as file options + if option.startswith("-"): + command.append(option) + else: + file_options.append(option) + + with open(tmpOptions, "w") as options_file: + options_file.write("\n".join(file_options)) + process = subprocess.run(command, stdout=sys.stdout, stderr=sys.stderr) + + # HiGHS return code semantics (see: https://github.com/ERGO-Code/HiGHS/issues/527#issuecomment-946575028) + # - -1: error + # - 0: success + # - 1: warning + if process.returncode == -1: + raise PulpSolverError("Error while executing HiGHS") + + with open(highs_log_file, "r") as log_file: + lines = log_file.readlines() + lines = [line.strip().split() for line in lines] + + # LP + model_line = [line for line in lines if line[:2] == ["Model", "status"]] + if len(model_line) > 0: + model_status = " ".join(model_line[0][3:]) # Model status: ... + else: + # ILP + model_line = [line for line in lines if "Status" in line][0] + model_status = " ".join(model_line[1:]) + sol_line = [line for line in lines if line[:2] == ["Solution", "status"]] + sol_line = sol_line[0] if len(sol_line) > 0 else ["Not solved"] + sol_status = sol_line[-1] + if model_status.lower() == "optimal": # optimal + status, status_sol = ( + constants.LpStatusOptimal, + constants.LpSolutionOptimal, + ) + elif sol_status.lower() == "feasible": # feasible + # Following the PuLP convention + status, status_sol = ( + constants.LpStatusOptimal, + constants.LpSolutionIntegerFeasible, + ) + elif model_status.lower() == "infeasible": # infeasible + status, status_sol = ( + constants.LpStatusInfeasible, + constants.LpSolutionNoSolutionFound, + ) + elif model_status.lower() == "unbounded": # unbounded + status, status_sol = ( + constants.LpStatusUnbounded, + constants.LpSolutionNoSolutionFound, + ) + else: # no solution + status, status_sol = ( + constants.LpStatusNotSolved, + constants.LpSolutionNoSolutionFound, + ) + + if not os.path.exists(tmpSol) or os.stat(tmpSol).st_size == 0: + status_sol = constants.LpSolutionNoSolutionFound + values = None + elif status_sol == constants.LpSolutionNoSolutionFound: + values = None + else: + values = self.readsol(lp.variables(), tmpSol) + + self.delete_tmp_files(tmpMps, tmpSol, tmpOptions, tmpLog) + lp.assignStatus(status, status_sol) + + if status == constants.LpStatusOptimal: + lp.assignVarsVals(values) + + return status + + @staticmethod + def readsol(variables, filename): + """Read a HiGHS solution file""" + with open(filename) as file: + lines = file.readlines() + + begin, end = None, None + for index, line in enumerate(lines): + if begin is None and line.startswith("# Columns"): + begin = index + 1 + if end is None and line.startswith("# Rows"): + end = index + if begin is None or end is None: + raise PulpSolverError("Cannot read HiGHS solver output") + + values = {} + for line in lines[begin:end]: + name, value = line.split() + values[name] = float(value) + return values + + +class HiGHS(LpSolver): + name = "HiGHS" + + try: + global highspy + import highspy + except: + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp, callback=None): + """Solve a well formulated lp problem""" + raise PulpSolverError("HiGHS: Not Available") + + else: + # Note(maciej): It was surprising to me that higshpy wasn't logging out of the box, + # even with the different logging options set. This callback seems to work, but there + # are probably better ways of doing this ¯\_(ツ)_/¯ + DEFAULT_CALLBACK = lambda logType, logMsg, callbackValue: print( + f"[{logType.name}] {logMsg}" + ) + DEFAULT_CALLBACK_VALUE = "" + + def __init__( + self, + mip=True, + msg=True, + callbackTuple=None, + gapAbs=None, + gapRel=None, + threads=None, + timeLimit=None, + **solverParams, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param tuple callbackTuple: Tuple of log callback function (see DEFAULT_CALLBACK above for definition) + and callbackValue (tag embedded in every callback) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param int threads: sets the maximum number of threads + :param float timeLimit: maximum time for solver (in seconds) + :param dict solverParams: list of named options to pass directly to the HiGHS solver + """ + super().__init__(mip=mip, msg=msg, timeLimit=timeLimit, **solverParams) + self.callbackTuple = callbackTuple + self.gapAbs = gapAbs + self.gapRel = gapRel + self.threads = threads + + def available(self): + return True + + def callSolver(self, lp): + lp.solverModel.run() + + def createAndConfigureSolver(self, lp): + lp.solverModel = highspy.Highs() + + if self.msg or self.callbackTuple: + callbackTuple = self.callbackTuple or ( + HiGHS.DEFAULT_CALLBACK, + HiGHS.DEFAULT_CALLBACK_VALUE, + ) + lp.solverModel.setLogCallback(*callbackTuple) + + if self.gapRel is not None: + lp.solverModel.setOptionValue("mip_rel_gap", self.gapRel) + + if self.gapAbs is not None: + lp.solverModel.setOptionValue("mip_abs_gap", self.gapAbs) + + if self.threads is not None: + lp.solverModel.setOptionValue("threads", self.threads) + + if self.timeLimit is not None: + lp.solverModel.setOptionValue("time_limit", float(self.timeLimit)) + + # set remaining parameter values + for key, value in self.optionsDict.items(): + lp.solverModel.setOptionValue(key, value) + + def buildSolverModel(self, lp): + inf = highspy.kHighsInf + + obj_mult = -1 if lp.sense == constants.LpMaximize else 1 + + for i, var in enumerate(lp.variables()): + lb = var.lowBound + ub = var.upBound + lp.solverModel.addCol( + obj_mult * lp.objective.get(var, 0.0), + -inf if lb is None else lb, + inf if ub is None else ub, + 0, + [], + [], + ) + var.index = i + + if var.cat == constants.LpInteger and self.mip: + lp.solverModel.changeColIntegrality( + var.index, highspy.HighsVarType.kInteger + ) + + for constraint in lp.constraints.values(): + non_zero_constraint_items = [ + (var.index, coefficient) + for var, coefficient in constraint.items() + if coefficient != 0 + ] + + if len(non_zero_constraint_items) == 0: + indices, coefficients = [], [] + else: + indices, coefficients = zip(*non_zero_constraint_items) + + lb = constraint.getLb() + ub = constraint.getUb() + lp.solverModel.addRow( + -inf if lb is None else lb, + inf if ub is None else ub, + len(indices), + indices, + coefficients, + ) + + def findSolutionValues(self, lp): + status = lp.solverModel.getModelStatus() + solution = lp.solverModel.getSolution() + HighsModelStatus = highspy.HighsModelStatus + status_dict = { + HighsModelStatus.kNotset: constants.LpStatusNotSolved, + HighsModelStatus.kLoadError: constants.LpStatusNotSolved, + HighsModelStatus.kModelError: constants.LpStatusNotSolved, + HighsModelStatus.kPresolveError: constants.LpStatusNotSolved, + HighsModelStatus.kSolveError: constants.LpStatusNotSolved, + HighsModelStatus.kPostsolveError: constants.LpStatusNotSolved, + HighsModelStatus.kModelEmpty: constants.LpStatusNotSolved, + HighsModelStatus.kOptimal: constants.LpStatusOptimal, + HighsModelStatus.kInfeasible: constants.LpStatusInfeasible, + HighsModelStatus.kUnboundedOrInfeasible: constants.LpStatusInfeasible, + HighsModelStatus.kUnbounded: constants.LpStatusUnbounded, + HighsModelStatus.kObjectiveBound: constants.LpStatusNotSolved, + HighsModelStatus.kObjectiveTarget: constants.LpStatusNotSolved, + HighsModelStatus.kTimeLimit: constants.LpStatusNotSolved, + HighsModelStatus.kIterationLimit: constants.LpStatusNotSolved, + HighsModelStatus.kUnknown: constants.LpStatusNotSolved, + } + + col_values = list(solution.col_value) + for var in lp.variables(): + var.varValue = col_values[var.index] + + return status_dict[status] + + def actualSolve(self, lp): + self.createAndConfigureSolver(lp) + self.buildSolverModel(lp) + self.callSolver(lp) + + solutionStatus = self.findSolutionValues(lp) + + for var in lp.variables(): + var.modified = False + + for constraint in lp.constraints.values(): + constraint.modifier = False + + return solutionStatus + + def actualResolve(self, lp, **kwargs): + raise PulpSolverError("HiGHS: Resolving is not supported") diff --git a/utils/pulp/apis/mipcl_api.py b/utils/pulp/apis/mipcl_api.py new file mode 100644 index 0000000..1632de7 --- /dev/null +++ b/utils/pulp/apis/mipcl_api.py @@ -0,0 +1,154 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +from .core import LpSolver_CMD, subprocess, PulpSolverError +import os +from .. import constants +import warnings + + +class MIPCL_CMD(LpSolver_CMD): + """The MIPCL_CMD solver""" + + name = "MIPCL_CMD" + + def __init__( + self, + path=None, + keepFiles=False, + mip=True, + msg=True, + options=None, + timeLimit=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param list options: list of additional options to pass to solver + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + """ + LpSolver_CMD.__init__( + self, + mip=mip, + msg=msg, + timeLimit=timeLimit, + options=options, + path=path, + keepFiles=keepFiles, + ) + + def defaultPath(self): + return self.executableExtension("mps_mipcl") + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + tmpMps, tmpSol = self.create_tmp_files(lp.name, "mps", "sol") + if lp.sense == constants.LpMaximize: + # we swap the objectives + # because it does not handle maximization. + warnings.warn( + "MIPCL_CMD does not allow maximization, " + "we will minimize the inverse of the objective function." + ) + lp += -lp.objective + lp.checkDuplicateVars() + lp.checkLengthVars(52) + lp.writeMPS(tmpMps, mpsSense=lp.sense) + + # just to report duplicated variables: + try: + os.remove(tmpSol) + except: + pass + cmd = self.path + cmd += f" {tmpMps}" + cmd += f" -solfile {tmpSol}" + if self.timeLimit is not None: + cmd += f" -time {self.timeLimit}" + for option in self.options: + cmd += " " + option + if lp.isMIP(): + if not self.mip: + warnings.warn("MIPCL_CMD cannot solve the relaxation of a problem") + if self.msg: + pipe = None + else: + pipe = open(os.devnull, "w") + + return_code = subprocess.call(cmd.split(), stdout=pipe, stderr=pipe) + # We need to undo the objective swap before finishing + if lp.sense == constants.LpMaximize: + lp += -lp.objective + if return_code != 0: + raise PulpSolverError("PuLP: Error while trying to execute " + self.path) + if not os.path.exists(tmpSol): + status = constants.LpStatusNotSolved + status_sol = constants.LpSolutionNoSolutionFound + values = None + else: + status, values, status_sol = self.readsol(tmpSol) + self.delete_tmp_files(tmpMps, tmpSol) + lp.assignStatus(status, status_sol) + if status not in [constants.LpStatusInfeasible, constants.LpStatusNotSolved]: + lp.assignVarsVals(values) + + return status + + @staticmethod + def readsol(filename): + """Read a MIPCL solution file""" + with open(filename) as f: + content = f.readlines() + content = [l.strip() for l in content] + values = {} + if not len(content): + return ( + constants.LpStatusNotSolved, + values, + constants.LpSolutionNoSolutionFound, + ) + first_line = content[0] + if first_line == "=infeas=": + return constants.LpStatusInfeasible, values, constants.LpSolutionInfeasible + objective, value = first_line.split() + # this is a workaround. + # Not sure if it always returns this limit when unbounded. + if abs(float(value)) >= 9.999999995e10: + return constants.LpStatusUnbounded, values, constants.LpSolutionUnbounded + for line in content[1:]: + name, value = line.split() + values[name] = float(value) + # I'm not sure how this solver announces the optimality + # of a solution so we assume it is integer feasible + return constants.LpStatusOptimal, values, constants.LpSolutionIntegerFeasible diff --git a/utils/pulp/apis/mosek_api.py b/utils/pulp/apis/mosek_api.py new file mode 100644 index 0000000..154bbad --- /dev/null +++ b/utils/pulp/apis/mosek_api.py @@ -0,0 +1,345 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +from .core import LpSolver, PulpSolverError +from .. import constants +import sys + +from typing import Optional + + +class MOSEK(LpSolver): + """Mosek lp and mip solver (via Mosek Optimizer API).""" + + name = "MOSEK" + try: + global mosek + import mosek + + env = mosek.Env() + except ImportError: + + def available(self): + """True if Mosek is available.""" + return False + + def actualSolve(self, lp, callback=None): + """Solves a well-formulated lp problem.""" + raise PulpSolverError("MOSEK : Not Available") + + else: + + def __init__( + self, + mip=True, + msg=True, + timeLimit: Optional[float] = None, + options: Optional[dict] = None, + task_file_name="", + sol_type=mosek.soltype.bas, + ): + """Initializes the Mosek solver. + + Keyword arguments: + + @param mip: If False, then solve MIP as LP. + + @param msg: Enable Mosek log output. + + @param float timeLimit: maximum time for solver (in seconds) + + @param options: Accepts a dictionary of Mosek solver parameters. Ignore to + use default parameter values. Eg: options = {mosek.dparam.mio_max_time:30} + sets the maximum time spent by the Mixed Integer optimizer to 30 seconds. + Equivalently, one could also write: options = {"MSK_DPAR_MIO_MAX_TIME":30} + which uses the generic parameter name as used within the solver, instead of + using an object from the Mosek Optimizer API (Python), as before. + + @param task_file_name: Writes a Mosek task file of the given name. By default, + no task file will be written. Eg: task_file_name = "eg1.opf". + + @param sol_type: Mosek supports three types of solutions: mosek.soltype.bas + (Basic solution, default), mosek.soltype.itr (Interior-point + solution) and mosek.soltype.itg (Integer solution). + + For a full list of Mosek parameters (for the Mosek Optimizer API) and supported task file + formats, please see https://docs.mosek.com/9.1/pythonapi/parameters.html#doc-all-parameter-list. + """ + self.mip = mip + self.msg = msg + self.timeLimit = timeLimit + self.task_file_name = task_file_name + self.solution_type = sol_type + if options is None: + options = {} + self.options = options + if self.timeLimit is not None: + timeLimit_keys = {"MSK_DPAR_MIO_MAX_TIME", mosek.dparam.mio_max_time} + if not timeLimit_keys.isdisjoint(self.options.keys()): + raise ValueError( + "timeLimit parameter has been provided trough `timeLimit` and `options`." + ) + self.options["MSK_DPAR_MIO_MAX_TIME"] = self.timeLimit + + def available(self): + """True if Mosek is available.""" + return True + + def setOutStream(self, text): + """Sets the log-output stream.""" + sys.stdout.write(text) + sys.stdout.flush() + + def buildSolverModel(self, lp, inf=1e20): + """Translate the problem into a Mosek task object.""" + self.cons = lp.constraints + self.numcons = len(self.cons) + self.cons_dict = {} + i = 0 + for c in self.cons: + self.cons_dict[c] = i + i = i + 1 + self.vars = list(lp.variables()) + self.numvars = len(self.vars) + self.var_dict = {} + # Checking for repeated names + lp.checkDuplicateVars() + self.task = MOSEK.env.Task() + self.task.appendcons(self.numcons) + self.task.appendvars(self.numvars) + if self.msg: + self.task.set_Stream(mosek.streamtype.log, self.setOutStream) + # Adding variables + for i in range(self.numvars): + vname = self.vars[i].name + self.var_dict[vname] = i + self.task.putvarname(i, vname) + # Variable type (Default: Continuous) + if self.mip & (self.vars[i].cat == constants.LpInteger): + self.task.putvartype(i, mosek.variabletype.type_int) + self.solution_type = mosek.soltype.itg + # Variable bounds + vbkey = mosek.boundkey.fr + vup = inf + vlow = -inf + if self.vars[i].lowBound != None: + vlow = self.vars[i].lowBound + if self.vars[i].upBound != None: + vup = self.vars[i].upBound + vbkey = mosek.boundkey.ra + else: + vbkey = mosek.boundkey.lo + elif self.vars[i].upBound != None: + vup = self.vars[i].upBound + vbkey = mosek.boundkey.up + self.task.putvarbound(i, vbkey, vlow, vup) + # Objective coefficient for the current variable. + self.task.putcj(i, lp.objective.get(self.vars[i], 0.0)) + # Coefficient matrix + self.A_rows, self.A_cols, self.A_vals = zip( + *[ + [self.cons_dict[row], self.var_dict[col], coeff] + for col, row, coeff in lp.coefficients() + ] + ) + self.task.putaijlist(self.A_rows, self.A_cols, self.A_vals) + # Constraints + self.constraint_data_list = [] + for c in self.cons: + cname = self.cons[c].name + if cname != None: + self.task.putconname(self.cons_dict[c], cname) + else: + self.task.putconname(self.cons_dict[c], c) + csense = self.cons[c].sense + cconst = -self.cons[c].constant + clow = -inf + cup = inf + # Constraint bounds + if csense == constants.LpConstraintEQ: + cbkey = mosek.boundkey.fx + clow = cconst + cup = cconst + elif csense == constants.LpConstraintGE: + cbkey = mosek.boundkey.lo + clow = cconst + elif csense == constants.LpConstraintLE: + cbkey = mosek.boundkey.up + cup = cconst + else: + raise PulpSolverError("Invalid constraint type.") + self.constraint_data_list.append([self.cons_dict[c], cbkey, clow, cup]) + self.cons_id_list, self.cbkey_list, self.clow_list, self.cup_list = zip( + *self.constraint_data_list + ) + self.task.putconboundlist( + self.cons_id_list, self.cbkey_list, self.clow_list, self.cup_list + ) + # Objective sense + if lp.sense == constants.LpMaximize: + self.task.putobjsense(mosek.objsense.maximize) + else: + self.task.putobjsense(mosek.objsense.minimize) + + def findSolutionValues(self, lp): + """ + Read the solution values and status from the Mosek task object. Note: Since the status + map from mosek.solsta to LpStatus is not exact, it is recommended that one enables the + log output and then refer to Mosek documentation for a better understanding of the + solution (especially in the case of mip problems). + """ + self.solsta = self.task.getsolsta(self.solution_type) + self.solution_status_dict = { + mosek.solsta.optimal: constants.LpStatusOptimal, + mosek.solsta.prim_infeas_cer: constants.LpStatusInfeasible, + mosek.solsta.dual_infeas_cer: constants.LpStatusUnbounded, + mosek.solsta.unknown: constants.LpStatusUndefined, + mosek.solsta.integer_optimal: constants.LpStatusOptimal, + mosek.solsta.prim_illposed_cer: constants.LpStatusNotSolved, + mosek.solsta.dual_illposed_cer: constants.LpStatusNotSolved, + mosek.solsta.prim_feas: constants.LpStatusNotSolved, + mosek.solsta.dual_feas: constants.LpStatusNotSolved, + mosek.solsta.prim_and_dual_feas: constants.LpStatusNotSolved, + } + # Variable values. + try: + self.xx = [0.0] * self.numvars + self.task.getxx(self.solution_type, self.xx) + for var in lp.variables(): + var.varValue = self.xx[self.var_dict[var.name]] + except mosek.Error: + pass + # Constraint slack variables. + try: + self.xc = [0.0] * self.numcons + self.task.getxc(self.solution_type, self.xc) + for con in lp.constraints: + lp.constraints[con].slack = -( + self.cons[con].constant + self.xc[self.cons_dict[con]] + ) + except mosek.Error: + pass + # Reduced costs. + if self.solution_type != mosek.soltype.itg: + try: + self.x_rc = [0.0] * self.numvars + self.task.getreducedcosts( + self.solution_type, 0, self.numvars, self.x_rc + ) + for var in lp.variables(): + var.dj = self.x_rc[self.var_dict[var.name]] + except mosek.Error: + pass + # Constraint Pi variables. + try: + self.y = [0.0] * self.numcons + self.task.gety(self.solution_type, self.y) + for con in lp.constraints: + lp.constraints[con].pi = self.y[self.cons_dict[con]] + except mosek.Error: + pass + + def putparam(self, par, val): + """ + Pass the values of valid parameters to Mosek. + """ + if isinstance(par, mosek.dparam): + self.task.putdouparam(par, val) + elif isinstance(par, mosek.iparam): + self.task.putintparam(par, val) + elif isinstance(par, mosek.sparam): + self.task.putstrparam(par, val) + elif isinstance(par, str): + if par.startswith("MSK_DPAR_"): + self.task.putnadouparam(par, val) + elif par.startswith("MSK_IPAR_"): + self.task.putnaintparam(par, val) + elif par.startswith("MSK_SPAR_"): + self.task.putnastrparam(par, val) + else: + raise PulpSolverError( + "Invalid MOSEK parameter: '{}'. Check MOSEK documentation for a list of valid parameters.".format( + par + ) + ) + + def actualSolve(self, lp): + """ + Solve a well-formulated lp problem. + """ + self.buildSolverModel(lp) + # Set solver parameters + for msk_par in self.options: + self.putparam(msk_par, self.options[msk_par]) + # Task file + if self.task_file_name: + self.task.writedata(self.task_file_name) + # Optimize + self.task.optimize() + # Mosek solver log (default: standard output stream) + if self.msg: + self.task.solutionsummary(mosek.streamtype.msg) + self.findSolutionValues(lp) + lp.assignStatus(self.solution_status_dict[self.solsta]) + for var in lp.variables(): + var.modified = False + for con in lp.constraints.values(): + con.modified = False + return lp.status + + def actualResolve(self, lp, inf=1e20, **kwargs): + """ + Modify constraints and re-solve an lp. The Mosek task object created in the first solve is used. + """ + for c in self.cons: + if self.cons[c].modified: + csense = self.cons[c].sense + cconst = -self.cons[c].constant + clow = -inf + cup = inf + # Constraint bounds + if csense == constants.LpConstraintEQ: + cbkey = mosek.boundkey.fx + clow = cconst + cup = cconst + elif csense == constants.LpConstraintGE: + cbkey = mosek.boundkey.lo + clow = cconst + elif csense == constants.LpConstraintLE: + cbkey = mosek.boundkey.up + cup = cconst + else: + raise PulpSolverError("Invalid constraint type.") + self.task.putconbound(self.cons_dict[c], cbkey, clow, cup) + # Re-solve + self.task.optimize() + self.findSolutionValues(lp) + lp.assignStatus(self.solution_status_dict[self.solsta]) + for var in lp.variables(): + var.modified = False + for con in lp.constraints.values(): + con.modified = False + return lp.status diff --git a/utils/pulp/apis/scip_api.py b/utils/pulp/apis/scip_api.py new file mode 100644 index 0000000..7864f55 --- /dev/null +++ b/utils/pulp/apis/scip_api.py @@ -0,0 +1,679 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +import operator +import os +import sys +import warnings + +from .core import LpSolver_CMD, LpSolver, subprocess, PulpSolverError +from .core import scip_path, fscip_path +from .. import constants + +from typing import Dict, List, Optional, Tuple + + +class SCIP_CMD(LpSolver_CMD): + """The SCIP optimization solver""" + + name = "SCIP_CMD" + + def __init__( + self, + path=None, + mip=True, + keepFiles=False, + msg=True, + options=None, + timeLimit=None, + gapRel=None, + gapAbs=None, + maxNodes=None, + logPath=None, + threads=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param list options: list of additional options to pass to solver + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param int maxNodes: max number of nodes during branching. Stops the solving when reached. + :param int threads: sets the maximum number of threads + :param str logPath: path to the log file + """ + LpSolver_CMD.__init__( + self, + mip=mip, + msg=msg, + options=options, + path=path, + keepFiles=keepFiles, + timeLimit=timeLimit, + gapRel=gapRel, + gapAbs=gapAbs, + maxNodes=maxNodes, + threads=threads, + logPath=logPath, + ) + + SCIP_STATUSES = { + "unknown": constants.LpStatusUndefined, + "user interrupt": constants.LpStatusNotSolved, + "node limit reached": constants.LpStatusNotSolved, + "total node limit reached": constants.LpStatusNotSolved, + "stall node limit reached": constants.LpStatusNotSolved, + "time limit reached": constants.LpStatusNotSolved, + "memory limit reached": constants.LpStatusNotSolved, + "gap limit reached": constants.LpStatusOptimal, + "solution limit reached": constants.LpStatusNotSolved, + "solution improvement limit reached": constants.LpStatusNotSolved, + "restart limit reached": constants.LpStatusNotSolved, + "optimal solution found": constants.LpStatusOptimal, + "infeasible": constants.LpStatusInfeasible, + "unbounded": constants.LpStatusUnbounded, + "infeasible or unbounded": constants.LpStatusNotSolved, + } + NO_SOLUTION_STATUSES = { + constants.LpStatusInfeasible, + constants.LpStatusUnbounded, + constants.LpStatusNotSolved, + } + + def defaultPath(self): + return self.executableExtension(scip_path) + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + + tmpLp, tmpSol, tmpOptions = self.create_tmp_files(lp.name, "lp", "sol", "set") + lp.writeLP(tmpLp) + + file_options: List[str] = [] + if self.timeLimit is not None: + file_options.append(f"limits/time={self.timeLimit}") + if "gapRel" in self.optionsDict: + file_options.append(f"limits/gap={self.optionsDict['gapRel']}") + if "gapAbs" in self.optionsDict: + file_options.append(f"limits/absgap={self.optionsDict['gapAbs']}") + if "maxNodes" in self.optionsDict: + file_options.append(f"limits/nodes={self.optionsDict['maxNodes']}") + if "threads" in self.optionsDict and int(self.optionsDict["threads"]) > 1: + warnings.warn( + "SCIP can only run with a single thread - use FSCIP_CMD for a parallel version of SCIP" + ) + if not self.mip: + warnings.warn(f"{self.name} does not allow a problem to be relaxed") + + command: List[str] = [] + command.append(self.path) + command.extend(["-s", tmpOptions]) + if not self.msg: + command.append("-q") + if "logPath" in self.optionsDict: + command.extend(["-l", self.optionsDict["logPath"]]) + + options = iter(self.options) + for option in options: + # identify cli options by a leading dash (-) and treat other options as file options + if option.startswith("-"): + # assumption: all cli options require an argument which is provided as a separate parameter + argument = next(options) + command.extend([option, argument]) + else: + # assumption: all file options require an argument which is provided after the equal sign (=) + if "=" not in option: + argument = next(options) + option += f"={argument}" + file_options.append(option) + + # append scip commands after parsing self.options to allow the user to specify additional -c arguments + command.extend(["-c", f'read "{tmpLp}"']) + command.extend(["-c", "optimize"]) + command.extend(["-c", f'write solution "{tmpSol}"']) + command.extend(["-c", "quit"]) + + with open(tmpOptions, "w") as options_file: + options_file.write("\n".join(file_options)) + subprocess.check_call(command, stdout=sys.stdout, stderr=sys.stderr) + + if not os.path.exists(tmpSol): + raise PulpSolverError("PuLP: Error while executing " + self.path) + status, values = self.readsol(tmpSol) + # Make sure to add back in any 0-valued variables SCIP leaves out. + finalVals = {} + for v in lp.variables(): + finalVals[v.name] = values.get(v.name, 0.0) + + lp.assignVarsVals(finalVals) + lp.assignStatus(status) + self.delete_tmp_files(tmpLp, tmpSol, tmpOptions) + return status + + @staticmethod + def readsol(filename): + """Read a SCIP solution file""" + with open(filename) as f: + # First line must contain 'solution status: ' + try: + line = f.readline() + comps = line.split(": ") + assert comps[0] == "solution status" + assert len(comps) == 2 + except Exception: + raise PulpSolverError(f"Can't get SCIP solver status: {line!r}") + + status = SCIP_CMD.SCIP_STATUSES.get( + comps[1].strip(), constants.LpStatusUndefined + ) + values = {} + + if status in SCIP_CMD.NO_SOLUTION_STATUSES: + return status, values + + # Look for an objective value. If we can't find one, stop. + try: + line = f.readline() + comps = line.split(": ") + assert comps[0] == "objective value" + assert len(comps) == 2 + float(comps[1].strip()) + except Exception: + raise PulpSolverError(f"Can't get SCIP solver objective: {line!r}") + + # Parse the variable values. + for line in f: + try: + comps = line.split() + values[comps[0]] = float(comps[1]) + except: + raise PulpSolverError(f"Can't read SCIP solver output: {line!r}") + + return status, values + + +SCIP = SCIP_CMD + + +class FSCIP_CMD(LpSolver_CMD): + """The multi-threaded FiberSCIP version of the SCIP optimization solver""" + + name = "FSCIP_CMD" + + def __init__( + self, + path=None, + mip=True, + keepFiles=False, + msg=True, + options=None, + timeLimit=None, + gapRel=None, + gapAbs=None, + maxNodes=None, + threads=None, + logPath=None, + ): + """ + :param bool msg: if False, no log is shown + :param bool mip: if False, assume LP even if integer variables + :param list options: list of additional options to pass to solver + :param bool keepFiles: if True, files are saved in the current directory and not deleted after solving + :param str path: path to the solver binary + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param int maxNodes: max number of nodes during branching. Stops the solving when reached. + :param int threads: sets the maximum number of threads + :param str logPath: path to the log file + """ + LpSolver_CMD.__init__( + self, + mip=mip, + msg=msg, + options=options, + path=path, + keepFiles=keepFiles, + timeLimit=timeLimit, + gapRel=gapRel, + gapAbs=gapAbs, + maxNodes=maxNodes, + threads=threads, + logPath=logPath, + ) + + FSCIP_STATUSES = { + "No Solution": constants.LpStatusNotSolved, + "Final Solution": constants.LpStatusOptimal, + } + NO_SOLUTION_STATUSES = { + constants.LpStatusInfeasible, + constants.LpStatusUnbounded, + constants.LpStatusNotSolved, + } + + def defaultPath(self): + return self.executableExtension(fscip_path) + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + + tmpLp, tmpSol, tmpOptions, tmpParams = self.create_tmp_files( + lp.name, "lp", "sol", "set", "prm" + ) + lp.writeLP(tmpLp) + + file_options: List[str] = [] + if self.timeLimit is not None: + file_options.append(f"limits/time={self.timeLimit}") + if "gapRel" in self.optionsDict: + file_options.append(f"limits/gap={self.optionsDict['gapRel']}") + if "gapAbs" in self.optionsDict: + file_options.append(f"limits/absgap={self.optionsDict['gapAbs']}") + if "maxNodes" in self.optionsDict: + file_options.append(f"limits/nodes={self.optionsDict['maxNodes']}") + if not self.mip: + warnings.warn(f"{self.name} does not allow a problem to be relaxed") + + file_parameters: List[str] = [] + # disable presolving in the LoadCoordinator to make sure a solution file is always written + file_parameters.append("NoPreprocessingInLC = TRUE") + + command: List[str] = [] + command.append(self.path) + command.append(tmpParams) + command.append(tmpLp) + command.extend(["-s", tmpOptions]) + command.extend(["-fsol", tmpSol]) + if not self.msg: + command.append("-q") + if "logPath" in self.optionsDict: + command.extend(["-l", self.optionsDict["logPath"]]) + if "threads" in self.optionsDict: + command.extend(["-sth", f"{self.optionsDict['threads']}"]) + + options = iter(self.options) + for option in options: + # identify cli options by a leading dash (-) and treat other options as file options + if option.startswith("-"): + # assumption: all cli options require an argument which is provided as a separate parameter + argument = next(options) + command.extend([option, argument]) + else: + # assumption: all file options contain a slash (/) + is_file_options = "/" in option + + # assumption: all file options and parameters require an argument which is provided after the equal sign (=) + if "=" not in option: + argument = next(options) + option += f"={argument}" + + if is_file_options: + file_options.append(option) + else: + file_parameters.append(option) + + # wipe the solution file since FSCIP does not overwrite it if no solution was found which causes parsing errors + self.silent_remove(tmpSol) + with open(tmpOptions, "w") as options_file: + options_file.write("\n".join(file_options)) + with open(tmpParams, "w") as parameters_file: + parameters_file.write("\n".join(file_parameters)) + subprocess.check_call( + command, + stdout=sys.stdout if self.msg else subprocess.DEVNULL, + stderr=sys.stderr if self.msg else subprocess.DEVNULL, + ) + + if not os.path.exists(tmpSol): + raise PulpSolverError("PuLP: Error while executing " + self.path) + status, values = self.readsol(tmpSol) + # Make sure to add back in any 0-valued variables SCIP leaves out. + finalVals = {} + for v in lp.variables(): + finalVals[v.name] = values.get(v.name, 0.0) + + lp.assignVarsVals(finalVals) + lp.assignStatus(status) + self.delete_tmp_files(tmpLp, tmpSol, tmpOptions, tmpParams) + return status + + @staticmethod + def parse_status(string: str) -> Optional[int]: + for fscip_status, pulp_status in FSCIP_CMD.FSCIP_STATUSES.items(): + if fscip_status in string: + return pulp_status + return None + + @staticmethod + def parse_objective(string: str) -> Optional[float]: + fields = string.split(":") + if len(fields) != 2: + return None + + label, objective = fields + if label != "objective value": + return None + + objective = objective.strip() + try: + objective = float(objective) + except ValueError: + return None + + return objective + + @staticmethod + def parse_variable(string: str) -> Optional[Tuple[str, float]]: + fields = string.split() + if len(fields) < 2: + return None + + name, value = fields[:2] + try: + value = float(value) + except ValueError: + return None + + return name, value + + @staticmethod + def readsol(filename): + """Read a FSCIP solution file""" + with open(filename) as file: + # First line must contain a solution status + status_line = file.readline() + status = FSCIP_CMD.parse_status(status_line) + if status is None: + raise PulpSolverError(f"Can't get FSCIP solver status: {status_line!r}") + + if status in FSCIP_CMD.NO_SOLUTION_STATUSES: + return status, {} + + # Look for an objective value. If we can't find one, stop. + objective_line = file.readline() + objective = FSCIP_CMD.parse_objective(objective_line) + if objective is None: + raise PulpSolverError( + f"Can't get FSCIP solver objective: {objective_line!r}" + ) + + # Parse the variable values. + variables: Dict[str, float] = {} + for variable_line in file: + variable = FSCIP_CMD.parse_variable(variable_line) + if variable is None: + raise PulpSolverError( + f"Can't read FSCIP solver output: {variable_line!r}" + ) + + name, value = variable + variables[name] = value + + return status, variables + + +FSCIP = FSCIP_CMD + + +class SCIP_PY(LpSolver): + """ + The SCIP Optimization Suite (via its python interface) + + The SCIP internals are available after calling solve as: + - each variable in variable.solverVar + - each constraint in constraint.solverConstraint + - the model in problem.solverModel + """ + + name = "SCIP_PY" + + try: + global scip + import pyscipopt as scip + + except ImportError: + + def available(self): + """True if the solver is available""" + return False + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + raise PulpSolverError(f"The {self.name} solver is not available") + + else: + + def __init__( + self, + mip=True, + msg=True, + options=None, + timeLimit=None, + gapRel=None, + gapAbs=None, + maxNodes=None, + logPath=None, + threads=None, + ): + """ + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param list options: list of additional options to pass to solver + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param float gapAbs: absolute gap tolerance for the solver to stop + :param int maxNodes: max number of nodes during branching. Stops the solving when reached. + :param str logPath: path to the log file + :param int threads: sets the maximum number of threads + """ + super().__init__( + mip=mip, + msg=msg, + options=options, + timeLimit=timeLimit, + gapRel=gapRel, + gapAbs=gapAbs, + maxNodes=maxNodes, + logPath=logPath, + threads=threads, + ) + + def findSolutionValues(self, lp): + lp.resolveOK = True + + solutionStatus = lp.solverModel.getStatus() + scip_to_pulp_status = { + "optimal": constants.LpStatusOptimal, + "unbounded": constants.LpStatusUnbounded, + "infeasible": constants.LpStatusInfeasible, + "inforunbd": constants.LpStatusNotSolved, + "timelimit": constants.LpStatusNotSolved, + "userinterrupt": constants.LpStatusNotSolved, + "nodelimit": constants.LpStatusNotSolved, + "totalnodelimit": constants.LpStatusNotSolved, + "stallnodelimit": constants.LpStatusNotSolved, + "gaplimit": constants.LpStatusNotSolved, + "memlimit": constants.LpStatusNotSolved, + "sollimit": constants.LpStatusNotSolved, + "bestsollimit": constants.LpStatusNotSolved, + "restartlimit": constants.LpStatusNotSolved, + "unknown": constants.LpStatusUndefined, + } + status = scip_to_pulp_status[solutionStatus] + lp.assignStatus(status) + + if status == constants.LpStatusOptimal: + solution = lp.solverModel.getBestSol() + for variable in lp._variables: + variable.varValue = solution[variable.solverVar] + for constraint in lp.constraints.values(): + constraint.slack = lp.solverModel.getSlack( + constraint.solverConstraint, solution + ) + + # TODO: check if problem is an LP i.e. does not have integer variables + # if : + # for variable in lp._variables: + # variable.dj = lp.solverModel.getVarRedcost(variable.solverVar) + # for constraint in lp.constraints.values(): + # constraint.pi = lp.solverModel.getDualSolVal(constraint.solverConstraint) + + return status + + def available(self): + """True if the solver is available""" + # if pyscipopt can be installed (and therefore imported) it has access to scip + return True + + def callSolver(self, lp): + """Solves the problem with scip""" + lp.solverModel.optimize() + + def buildSolverModel(self, lp): + """ + Takes the pulp lp model and translates it into a scip model + """ + ################################################## + # create model + ################################################## + lp.solverModel = scip.Model(lp.name) + if lp.sense == constants.LpMaximize: + lp.solverModel.setMaximize() + else: + lp.solverModel.setMinimize() + + ################################################## + # add options + ################################################## + if not self.msg: + lp.solverModel.hideOutput() + if self.timeLimit is not None: + lp.solverModel.setParam("limits/time", self.timeLimit) + if "gapRel" in self.optionsDict: + lp.solverModel.setParam("limits/gap", self.optionsDict["gapRel"]) + if "gapAbs" in self.optionsDict: + lp.solverModel.setParam("limits/absgap", self.optionsDict["gapAbs"]) + if "maxNodes" in self.optionsDict: + lp.solverModel.setParam("limits/nodes", self.optionsDict["maxNodes"]) + if "logPath" in self.optionsDict: + lp.solverModel.setLogfile(self.optionsDict["logPath"]) + if "threads" in self.optionsDict and int(self.optionsDict["threads"]) > 1: + warnings.warn( + f"The solver {self.name} can only run with a single thread" + ) + if not self.mip: + warnings.warn(f"{self.name} does not allow a problem to be relaxed") + + options = iter(self.options) + for option in options: + # assumption: all file options require an argument which is provided after the equal sign (=) + if "=" in option: + name, value = option.split("=", maxsplit=2) + else: + name, value = option, next(options) + lp.solverModel.setParam(name, value) + + ################################################## + # add variables + ################################################## + category_to_vtype = { + constants.LpBinary: "B", + constants.LpContinuous: "C", + constants.LpInteger: "I", + } + for var in lp.variables(): + var.solverVar = lp.solverModel.addVar( + name=var.name, + vtype=category_to_vtype[var.cat], + lb=var.lowBound, # a lower bound of None represents -infinity + ub=var.upBound, # an upper bound of None represents +infinity + obj=lp.objective.get(var, 0.0), + ) + + ################################################## + # add constraints + ################################################## + sense_to_operator = { + constants.LpConstraintLE: operator.le, + constants.LpConstraintGE: operator.ge, + constants.LpConstraintEQ: operator.eq, + } + for name, constraint in lp.constraints.items(): + constraint.solverConstraint = lp.solverModel.addCons( + cons=sense_to_operator[constraint.sense]( + scip.quicksum( + coefficient * variable.solverVar + for variable, coefficient in constraint.items() + ), + -constraint.constant, + ), + name=name, + ) + + def actualSolve(self, lp): + """ + Solve a well formulated lp problem + + creates a scip model, variables and constraints and attaches + them to the lp model which it then solves + """ + self.buildSolverModel(lp) + self.callSolver(lp) + solutionStatus = self.findSolutionValues(lp) + for variable in lp._variables: + variable.modified = False + for constraint in lp.constraints.values(): + constraint.modified = False + return solutionStatus + + def actualResolve(self, lp): + """ + Solve a well formulated lp problem + + uses the old solver and modifies the rhs of the modified constraints + """ + # TODO: add ability to resolve pysciptopt models + # - http://listserv.zib.de/pipermail/scip/2020-May/003977.html + # - https://scipopt.org/doc-8.0.0/html/REOPT.php + raise PulpSolverError( + f"The {self.name} solver does not implement resolving" + ) diff --git a/utils/pulp/apis/xpress_api.py b/utils/pulp/apis/xpress_api.py new file mode 100644 index 0000000..efdef5f --- /dev/null +++ b/utils/pulp/apis/xpress_api.py @@ -0,0 +1,760 @@ +# PuLP : Python LP Modeler +# Version 1.4.2 + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:solvers.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +from .core import LpSolver, LpSolver_CMD, subprocess, PulpSolverError +from .. import constants +import warnings +import sys +import re + + +def _ismip(lp): + """Check whether lp is a MIP. + + From an XPRESS point of view, a problem is also a MIP if it contains + SOS constraints.""" + return lp.isMIP() or len(lp.sos1) or len(lp.sos2) + + +class XPRESS(LpSolver_CMD): + """The XPRESS LP solver that uses the XPRESS command line tool + in a subprocess""" + + name = "XPRESS" + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + gapRel=None, + options=None, + keepFiles=False, + path=None, + maxSeconds=None, + targetGap=None, + heurFreq=None, + heurStra=None, + coverCuts=None, + preSolve=None, + warmStart=False, + ): + """ + Initializes the Xpress solver. + + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param maxSeconds: deprecated for timeLimit + :param targetGap: deprecated for gapRel + :param heurFreq: the frequency at which heuristics are used in the tree search + :param heurStra: heuristic strategy + :param coverCuts: the number of rounds of lifted cover inequalities at the top node + :param preSolve: whether presolving should be performed before the main algorithm + :param options: Adding more options, e.g. options = ["NODESELECTION=1", "HEURDEPTH=5"] + More about Xpress options and control parameters please see + https://www.fico.com/fico-xpress-optimization/docs/latest/solver/optimizer/HTML/chapter7.html + :param bool warmStart: if True, then use current variable values as start + """ + if maxSeconds: + warnings.warn("Parameter maxSeconds is being depreciated for timeLimit") + if timeLimit is not None: + warnings.warn( + "Parameter timeLimit and maxSeconds passed, using timeLimit" + ) + else: + timeLimit = maxSeconds + if targetGap is not None: + warnings.warn("Parameter targetGap is being depreciated for gapRel") + if gapRel is not None: + warnings.warn("Parameter gapRel and epgap passed, using gapRel") + else: + gapRel = targetGap + LpSolver_CMD.__init__( + self, + gapRel=gapRel, + mip=mip, + msg=msg, + timeLimit=timeLimit, + options=options, + path=path, + keepFiles=keepFiles, + heurFreq=heurFreq, + heurStra=heurStra, + coverCuts=coverCuts, + preSolve=preSolve, + warmStart=warmStart, + ) + + def defaultPath(self): + return self.executableExtension("optimizer") + + def available(self): + """True if the solver is available""" + return self.executable(self.path) + + def actualSolve(self, lp): + """Solve a well formulated lp problem""" + if not self.executable(self.path): + raise PulpSolverError("PuLP: cannot execute " + self.path) + tmpLp, tmpSol, tmpCmd, tmpAttr, tmpStart = self.create_tmp_files( + lp.name, "lp", "prt", "cmd", "attr", "slx" + ) + variables = lp.writeLP(tmpLp, writeSOS=1, mip=self.mip) + if self.optionsDict.get("warmStart", False): + start = [(v.name, v.value()) for v in variables if v.value() is not None] + self.writeslxsol(tmpStart, start) + # Explicitly capture some attributes so that we can easily get + # information about the solution. + attrNames = [] + if _ismip(lp) and self.mip: + attrNames.extend(["mipobjval", "bestbound", "mipstatus"]) + statusmap = { + 0: constants.LpStatusUndefined, # XPRS_MIP_NOT_LOADED + 1: constants.LpStatusUndefined, # XPRS_MIP_LP_NOT_OPTIMAL + 2: constants.LpStatusUndefined, # XPRS_MIP_LP_OPTIMAL + 3: constants.LpStatusUndefined, # XPRS_MIP_NO_SOL_FOUND + 4: constants.LpStatusUndefined, # XPRS_MIP_SOLUTION + 5: constants.LpStatusInfeasible, # XPRS_MIP_INFEAS + 6: constants.LpStatusOptimal, # XPRS_MIP_OPTIMAL + 7: constants.LpStatusUndefined, # XPRS_MIP_UNBOUNDED + } + statuskey = "mipstatus" + else: + attrNames.extend(["lpobjval", "lpstatus"]) + statusmap = { + 0: constants.LpStatusNotSolved, # XPRS_LP_UNSTARTED + 1: constants.LpStatusOptimal, # XPRS_LP_OPTIMAL + 2: constants.LpStatusInfeasible, # XPRS_LP_INFEAS + 3: constants.LpStatusUndefined, # XPRS_LP_CUTOFF + 4: constants.LpStatusUndefined, # XPRS_LP_UNFINISHED + 5: constants.LpStatusUnbounded, # XPRS_LP_UNBOUNDED + 6: constants.LpStatusUndefined, # XPRS_LP_CUTOFF_IN_DUAL + 7: constants.LpStatusNotSolved, # XPRS_LP_UNSOLVED + 8: constants.LpStatusUndefined, # XPRS_LP_NONCONVEX + } + statuskey = "lpstatus" + with open(tmpCmd, "w") as cmd: + if not self.msg: + cmd.write("OUTPUTLOG=0\n") + # The readprob command must be in lower case for correct filename handling + cmd.write("readprob " + self.quote_path(tmpLp) + "\n") + if self.timeLimit is not None: + cmd.write("MAXTIME=%d\n" % self.timeLimit) + targetGap = self.optionsDict.get("gapRel") + if targetGap is not None: + cmd.write(f"MIPRELSTOP={targetGap:f}\n") + heurFreq = self.optionsDict.get("heurFreq") + if heurFreq is not None: + cmd.write("HEURFREQ=%d\n" % heurFreq) + heurStra = self.optionsDict.get("heurStra") + if heurStra is not None: + cmd.write("HEURSTRATEGY=%d\n" % heurStra) + coverCuts = self.optionsDict.get("coverCuts") + if coverCuts is not None: + cmd.write("COVERCUTS=%d\n" % coverCuts) + preSolve = self.optionsDict.get("preSolve") + if preSolve is not None: + cmd.write("PRESOLVE=%d\n" % preSolve) + if self.optionsDict.get("warmStart", False): + cmd.write("readslxsol " + self.quote_path(tmpStart) + "\n") + for option in self.options: + cmd.write(option + "\n") + if _ismip(lp) and self.mip: + cmd.write("mipoptimize\n") + else: + cmd.write("lpoptimize\n") + # The writeprtsol command must be in lower case for correct filename handling + cmd.write("writeprtsol " + self.quote_path(tmpSol) + "\n") + cmd.write( + f"set fh [open {self.quote_path(tmpAttr)} w]; list\n" + ) # `list` to suppress output + + for attr in attrNames: + cmd.write(f'puts $fh "{attr}=${attr}"\n') + cmd.write("close $fh\n") + cmd.write("QUIT\n") + with open(tmpCmd) as cmd: + consume = False + subout = None + suberr = None + if not self.msg: + # Xpress writes a banner before we can disable output. So + # we have to explicitly consume the banner. + if sys.hexversion >= 0x03030000: + subout = subprocess.DEVNULL + suberr = subprocess.DEVNULL + else: + # We could also use open(os.devnull, 'w') but then we + # would be responsible for closing the file. + subout = subprocess.PIPE + suberr = subprocess.STDOUT + consume = True + xpress = subprocess.Popen( + [self.path, lp.name], + shell=True, + stdin=cmd, + stdout=subout, + stderr=suberr, + universal_newlines=True, + ) + if consume: + # Special case in which messages are disabled and we have + # to consume any output + for _ in xpress.stdout: + pass + + if xpress.wait() != 0: + raise PulpSolverError("PuLP: Error while executing " + self.path) + values, redcost, slacks, duals, attrs = self.readsol(tmpSol, tmpAttr) + self.delete_tmp_files(tmpLp, tmpSol, tmpCmd, tmpAttr) + status = statusmap.get(attrs.get(statuskey, -1), constants.LpStatusUndefined) + lp.assignVarsVals(values) + lp.assignVarsDj(redcost) + lp.assignConsSlack(slacks) + lp.assignConsPi(duals) + lp.assignStatus(status) + return status + + @staticmethod + def readsol(filename, attrfile): + """Read an XPRESS solution file""" + values = {} + redcost = {} + slacks = {} + duals = {} + with open(filename) as f: + for lineno, _line in enumerate(f): + # The first 6 lines are status information + if lineno < 6: + continue + elif lineno == 6: + # Line with status information + _line = _line.split() + rows = int(_line[2]) + cols = int(_line[5]) + elif lineno < 10: + # Empty line, "Solution Statistics", objective direction + pass + elif lineno == 10: + # Solution status + pass + else: + # There is some more stuff and then follows the "Rows" and + # "Columns" section. That other stuff does not match the + # format of the rows/columns lines, so we can keep the + # parser simple + line = _line.split() + if len(line) > 1: + if line[0] == "C": + # A column + # (C, Number, Name, At, Value, Input Cost, Reduced Cost) + name = line[2] + values[name] = float(line[4]) + redcost[name] = float(line[6]) + elif len(line[0]) == 1 and line[0] in "LGRE": + # A row + # ([LGRE], Number, Name, At, Value, Slack, Dual, RHS) + name = line[2] + slacks[name] = float(line[5]) + duals[name] = float(line[6]) + # Read the attributes that we wrote explicitly + attrs = dict() + with open(attrfile) as f: + for line in f: + fields = line.strip().split("=") + if len(fields) == 2 and fields[0].lower() == fields[0]: + value = fields[1].strip() + try: + value = int(fields[1].strip()) + except ValueError: + try: + value = float(fields[1].strip()) + except ValueError: + pass + attrs[fields[0].strip()] = value + return values, redcost, slacks, duals, attrs + + def writeslxsol(self, name, *values): + """ + Write a solution file in SLX format. + The function can write multiple solutions to the same file, each + solution must be passed as a list of (name,value) pairs. Solutions + are written in the order specified and are given names "solutionN" + where N is the index of the solution in the list. + + :param string name: file name + :param list values: list of lists of (name,value) pairs + """ + with open(name, "w") as slx: + for i, sol in enumerate(values): + slx.write("NAME solution%d\n" % i) + for name, value in sol: + slx.write(f" C {name} {value:.16f}\n") + slx.write("ENDATA\n") + + @staticmethod + def quote_path(path): + r""" + Quotes a path for the Xpress optimizer console, by wrapping it in + double quotes and escaping the following characters, which would + otherwise be interpreted by the Tcl shell: \ $ " [ + """ + return '"' + re.sub(r'([\\$"[])', r"\\\1", path) + '"' + + +XPRESS_CMD = XPRESS + +xpress = None + + +class XPRESS_PY(LpSolver): + """The XPRESS LP solver that uses XPRESS Python API""" + + name = "XPRESS_PY" + + def __init__( + self, + mip=True, + msg=True, + timeLimit=None, + gapRel=None, + heurFreq=None, + heurStra=None, + coverCuts=None, + preSolve=None, + warmStart=None, + export=None, + options=None, + ): + """ + Initializes the Xpress solver. + + :param bool mip: if False, assume LP even if integer variables + :param bool msg: if False, no log is shown + :param float timeLimit: maximum time for solver (in seconds) + :param float gapRel: relative gap tolerance for the solver to stop (in fraction) + :param heurFreq: the frequency at which heuristics are used in the tree search + :param heurStra: heuristic strategy + :param coverCuts: the number of rounds of lifted cover inequalities at the top node + :param preSolve: whether presolving should be performed before the main algorithm + :param bool warmStart: if set then use current variable values as warm start + :param string export: if set then the model will be exported to this file before solving + :param options: Adding more options. This is a list the elements of which + are either (name,value) pairs or strings "name=value". + More about Xpress options and control parameters please see + https://www.fico.com/fico-xpress-optimization/docs/latest/solver/optimizer/HTML/chapter7.html + """ + if timeLimit is not None: + # The Xpress time limit has this interpretation: + # timelimit <0: Stop after -timelimit, no matter what + # timelimit >0: Stop after timelimit only if a feasible solution + # exists. We overwrite this meaning here since it is + # somewhat counterintuitive when compared to other + # solvers. You can always pass a positive timlimit + # via `options` to get that behavior. + timeLimit = -abs(timeLimit) + LpSolver.__init__( + self, + gapRel=gapRel, + mip=mip, + msg=msg, + timeLimit=timeLimit, + options=options, + heurFreq=heurFreq, + heurStra=heurStra, + coverCuts=coverCuts, + preSolve=preSolve, + warmStart=warmStart, + ) + self._available = None + self._export = export + + def available(self): + """True if the solver is available""" + if self._available is None: + try: + global xpress + import xpress + + # Always disable the global output. We only want output if + # we install callbacks explicitly + xpress.setOutputEnabled(False) + self._available = True + except: + self._available = False + return self._available + + def callSolver(self, lp, prepare=None): + """Perform the actual solve from actualSolve() or actualResolve(). + + :param prepare: a function that is called with `lp` as argument + and allows final tweaks to `lp.solverModel` before + the low level solve is started. + """ + try: + model = lp.solverModel + # Mark all variables and constraints as unmodified so that + # actualResolve will do the correct thing. + for v in lp.variables(): + v.modified = False + for c in lp.constraints.values(): + c.modified = False + + if self._export is not None: + if self._export.lower().endswith(".lp"): + model.write(self._export, "l") + else: + model.write(self._export) + if prepare is not None: + prepare(lp) + if _ismip(lp) and not self.mip: + # Solve only the LP relaxation + model.lpoptimize() + else: + # In all other cases, solve() does the correct thing + model.solve() + except (xpress.ModelError, xpress.InterfaceError, xpress.SolverError) as err: + raise PulpSolverError(str(err)) + + def findSolutionValues(self, lp): + try: + model = lp.solverModel + # Collect results + if _ismip(lp) and self.mip: + # Solved as MIP + x, slacks, duals, djs = [], [], None, None + try: + model.getmipsol(x, slacks) + except: + x, slacks = None, None + statusmap = { + 0: constants.LpStatusUndefined, # XPRS_MIP_NOT_LOADED + 1: constants.LpStatusUndefined, # XPRS_MIP_LP_NOT_OPTIMAL + 2: constants.LpStatusUndefined, # XPRS_MIP_LP_OPTIMAL + 3: constants.LpStatusUndefined, # XPRS_MIP_NO_SOL_FOUND + 4: constants.LpStatusUndefined, # XPRS_MIP_SOLUTION + 5: constants.LpStatusInfeasible, # XPRS_MIP_INFEAS + 6: constants.LpStatusOptimal, # XPRS_MIP_OPTIMAL + 7: constants.LpStatusUndefined, # XPRS_MIP_UNBOUNDED + } + statuskey = "mipstatus" + else: + # Solved as continuous + x, slacks, duals, djs = [], [], [], [] + try: + model.getlpsol(x, slacks, duals, djs) + except: + # No solution available + x, slacks, duals, djs = None, None, None, None + statusmap = { + 0: constants.LpStatusNotSolved, # XPRS_LP_UNSTARTED + 1: constants.LpStatusOptimal, # XPRS_LP_OPTIMAL + 2: constants.LpStatusInfeasible, # XPRS_LP_INFEAS + 3: constants.LpStatusUndefined, # XPRS_LP_CUTOFF + 4: constants.LpStatusUndefined, # XPRS_LP_UNFINISHED + 5: constants.LpStatusUnbounded, # XPRS_LP_UNBOUNDED + 6: constants.LpStatusUndefined, # XPRS_LP_CUTOFF_IN_DUAL + 7: constants.LpStatusNotSolved, # XPRS_LP_UNSOLVED + 8: constants.LpStatusUndefined, # XPRS_LP_NONCONVEX + } + statuskey = "lpstatus" + if x is not None: + lp.assignVarsVals({v.name: x[v._xprs[0]] for v in lp.variables()}) + if djs is not None: + lp.assignVarsDj({v.name: djs[v._xprs[0]] for v in lp.variables()}) + if duals is not None: + lp.assignConsPi( + {c.name: duals[c._xprs[0]] for c in lp.constraints.values()} + ) + if slacks is not None: + lp.assignConsSlack( + {c.name: slacks[c._xprs[0]] for c in lp.constraints.values()} + ) + + status = statusmap.get( + model.getAttrib(statuskey), constants.LpStatusUndefined + ) + lp.assignStatus(status) + + return status + + except (xpress.ModelError, xpress.InterfaceError, xpress.SolverError) as err: + raise PulpSolverError(str(err)) + + def actualSolve(self, lp, prepare=None): + """Solve a well formulated lp problem""" + if not self.available(): + # Import again to get a more verbose error message + message = "XPRESS Python API not available" + try: + import xpress + except ImportError as err: + message = str(err) + raise PulpSolverError(message) + + self.buildSolverModel(lp) + self.callSolver(lp, prepare) + return self.findSolutionValues(lp) + + def buildSolverModel(self, lp): + """ + Takes the pulp lp model and translates it into an xpress model + """ + self._extract(lp) + try: + # Apply controls, warmstart etc. We do this here rather than in + # callSolver() so that the caller has a chance to overwrite things + # either using the `prepare` argument to callSolver() or by + # explicitly calling + # self.buildSolverModel() + # self.callSolver() + # self.findSolutionValues() + # This also avoids setting warmstart information passed to the + # constructor from actualResolve(), which would almost certainly + # be unintended. + model = lp.solverModel + # Apply controls that were passed to the constructor + for key, name in [ + ("gapRel", "MIPRELSTOP"), + ("timeLimit", "MAXTIME"), + ("heurFreq", "HEURFREQ"), + ("heurStra", "HEURSTRATEGY"), + ("coverCuts", "COVERCUTS"), + ("preSolve", "PRESOLVE"), + ]: + value = self.optionsDict.get(key, None) + if value is not None: + model.setControl(name, value) + + # Apply any other controls. These overwrite controls that were + # passed explicitly into the constructor. + for option in self.options: + if isinstance(option, tuple): + name = optione[0] + value = option[1] + else: + fields = option.split("=", 1) + if len(fields) != 2: + raise PulpSolverError("Invalid option " + str(option)) + name = fields[0].strip() + value = fields[1].strip() + try: + model.setControl(name, int(value)) + continue + except ValueError: + pass + try: + model.setControl(name, float(value)) + continue + except ValueError: + pass + model.setControl(name, value) + # Setup warmstart information + if self.optionsDict.get("warmStart", False): + solval = list() + colind = list() + for v in sorted(lp.variables(), key=lambda x: x._xprs[0]): + if v.value() is not None: + solval.append(v.value()) + colind.append(v._xprs[0]) + if _ismip(lp) and self.mip: + # If we have a value for every variable then use + # loadmipsol(), which requires a dense solution. Otherwise + # use addmipsol() which allows sparse vectors. + if len(solval) == model.attributes.cols: + model.loadmipsol(solval) + else: + model.addmipsol(solval, colind, "warmstart") + else: + model.loadlpsol(solval, None, None, None) + # Setup message callback if output is requested + if self.msg: + + def message(prob, data, msg, msgtype): + if msgtype > 0: + print(msg) + + model.addcbmessage(message) + except (xpress.ModelError, xpress.InterfaceError, xpress.SolverError) as err: + raise PulpSolverError(str(err)) + + def actualResolve(self, lp, prepare=None): + """Resolve a problem that was previously solved by actualSolve().""" + try: + rhsind = list() + rhsval = list() + for name in sorted(lp.constraints): + con = lp.constraints[name] + if not con.modified: + continue + if not hasattr(con, "_xprs"): + # Adding constraints is not implemented at the moment + raise PulpSolverError("Cannot add new constraints") + # At the moment only RHS can change in pulp.py + rhsind.append(con._xprs[0]) + rhsval.append(-con.constant) + if len(rhsind) > 0: + lp.solverModel.chgrhs(rhsind, rhsval) + + bndind = list() + bndtype = list() + bndval = list() + for v in lp.variables(): + if not v.modified: + continue + if not hasattr(v, "_xprs"): + # Adding variables is not implemented at the moment + raise PulpSolverError("Cannot add new variables") + # At the moment only bounds can change in pulp.py + bndind.append(v._xprs[0]) + bndtype.append("L") + bndval.append(-xpress.infinity if v.lowBound is None else v.lowBound) + bndind.append(v._xprs[0]) + bndtype.append("G") + bndval.append(xpress.infinity if v.upBound is None else v.upBound) + if len(bndtype) > 0: + lp.solverModel.chgbounds(bndind, bndtype, bndval) + + self.callSolver(lp, prepare) + return self.findSolutionValues(lp) + except (xpress.ModelError, xpress.InterfaceError, xpress.SolverError) as err: + raise PulpSolverError(str(err)) + + @staticmethod + def _reset(lp): + """Reset any XPRESS specific information in lp.""" + if hasattr(lp, "solverModel"): + delattr(lp, "solverModel") + for v in lp.variables(): + if hasattr(v, "_xprs"): + delattr(v, "_xprs") + for c in lp.constraints.values(): + if hasattr(c, "_xprs"): + delattr(c, "_xprs") + + def _extract(self, lp): + """Extract a given model to an XPRESS Python API instance. + + The function stores XPRESS specific information in the `solverModel` property + of `lp` and each variable and constraint. These information can be + removed by calling `_reset`. + """ + self._reset(lp) + try: + model = xpress.problem() + if lp.sense == constants.LpMaximize: + model.chgobjsense(xpress.maximize) + + # Create variables. We first collect the info for all variables + # and then create all of them in one shot. This is supposed to + # be faster in case we have to create a lot of variables. + obj = list() + lb = list() + ub = list() + ctype = list() + names = list() + for v in lp.variables(): + lb.append(-xpress.infinity if v.lowBound is None else v.lowBound) + ub.append(xpress.infinity if v.upBound is None else v.upBound) + obj.append(lp.objective.get(v, 0.0)) + if v.cat == constants.LpInteger: + ctype.append("I") + elif v.cat == constants.LpBinary: + ctype.append("B") + else: + ctype.append("C") + names.append(v.name) + model.addcols(obj, [0] * (len(obj) + 1), [], [], lb, ub, names, ctype) + for j, (v, x) in enumerate(zip(lp.variables(), model.getVariable())): + v._xprs = (j, x) + + # Generate constraints. Sort by name to get deterministic + # ordering of constraints. + # Constraints are generated in blocks of 100 constraints to speed + # up things a bit but still keep memory usage small. + cons = list() + for i, name in enumerate(sorted(lp.constraints)): + con = lp.constraints[name] + # Sort the variables by index to get deterministic + # ordering of variables in the row. + lhs = xpress.Sum( + a * x._xprs[1] + for x, a in sorted(con.items(), key=lambda x: x[0]._xprs[0]) + ) + rhs = -con.constant + if con.sense == constants.LpConstraintLE: + c = xpress.constraint(body=lhs, sense=xpress.leq, rhs=rhs) + elif con.sense == constants.LpConstraintGE: + c = xpress.constraint(body=lhs, sense=xpress.geq, rhs=rhs) + elif con.sense == constants.LpConstraintEQ: + c = xpress.constraint(body=lhs, sense=xpress.eq, rhs=rhs) + else: + raise PulpSolverError( + "Unsupprted constraint type " + str(con.sense) + ) + cons.append((i, c, con)) + if len(cons) > 100: + model.addConstraint([c for _, c, _ in cons]) + for i, c, con in cons: + con._xprs = (i, c) + cons = list() + if len(cons) > 0: + model.addConstraint([c for _, c, _ in cons]) + for i, c, con in cons: + con._xprs = (i, c) + + # SOS constraints + def addsos(m, sosdict, sostype): + """Extract sos constraints from PuLP.""" + soslist = [] + # Sort by name to get deterministic ordering. Note that + # names may be plain integers, that is why we use str(name) + # to pass them to the SOS constructor. + for name in sorted(sosdict): + indices = [] + weights = [] + for v, val in sosdict[name].items(): + indices.append(v._xprs[0]) + weights.append(val) + soslist.append(xpress.sos(indices, weights, sostype, str(name))) + if len(soslist): + m.addSOS(soslist) + + addsos(model, lp.sos1, 1) + addsos(model, lp.sos2, 2) + + lp.solverModel = model + except (xpress.ModelError, xpress.InterfaceError, xpress.SolverError) as err: + # Undo everything + self._reset(lp) + raise PulpSolverError(str(err)) + + def getAttribute(self, lp, which): + """Get an arbitrary attribute for the model that was previously + solved using actualSolve().""" + return lp.solverModel.getAttrib(which) diff --git a/utils/pulp/constants.py b/utils/pulp/constants.py new file mode 100644 index 0000000..cb037be --- /dev/null +++ b/utils/pulp/constants.py @@ -0,0 +1,104 @@ +# PuLP : Python LP Modeler + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id:constants.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" + +""" +This file contains the constant definitions for PuLP +Note that hopefully these will be changed into something more pythonic +""" +VERSION = "2.7.0" +EPS = 1e-7 + +# variable categories +LpContinuous = "Continuous" +LpInteger = "Integer" +LpBinary = "Binary" +LpCategories = {LpContinuous: "Continuous", LpInteger: "Integer", LpBinary: "Binary"} + +# objective sense +LpMinimize = 1 +LpMaximize = -1 +LpSenses = {LpMaximize: "Maximize", LpMinimize: "Minimize"} +LpSensesMPS = {LpMaximize: "MAX", LpMinimize: "MIN"} + +# problem status +LpStatusNotSolved = 0 +LpStatusOptimal = 1 +LpStatusInfeasible = -1 +LpStatusUnbounded = -2 +LpStatusUndefined = -3 +LpStatus = { + LpStatusNotSolved: "Not Solved", + LpStatusOptimal: "Optimal", + LpStatusInfeasible: "Infeasible", + LpStatusUnbounded: "Unbounded", + LpStatusUndefined: "Undefined", +} + +# solution status +LpSolutionNoSolutionFound = 0 +LpSolutionOptimal = 1 +LpSolutionIntegerFeasible = 2 +LpSolutionInfeasible = -1 +LpSolutionUnbounded = -2 +LpSolution = { + LpSolutionNoSolutionFound: "No Solution Found", + LpSolutionOptimal: "Optimal Solution Found", + LpSolutionIntegerFeasible: "Solution Found", + LpSolutionInfeasible: "No Solution Exists", + LpSolutionUnbounded: "Solution is Unbounded", +} +LpStatusToSolution = { + LpStatusNotSolved: LpSolutionInfeasible, + LpStatusOptimal: LpSolutionOptimal, + LpStatusInfeasible: LpSolutionInfeasible, + LpStatusUnbounded: LpSolutionUnbounded, + LpStatusUndefined: LpSolutionInfeasible, +} + +# constraint sense +LpConstraintLE = -1 +LpConstraintEQ = 0 +LpConstraintGE = 1 +LpConstraintTypeToMps = {LpConstraintLE: "L", LpConstraintEQ: "E", LpConstraintGE: "G"} +LpConstraintSenses = {LpConstraintEQ: "=", LpConstraintLE: "<=", LpConstraintGE: ">="} +# LP line size +LpCplexLPLineSize = 78 + + +def isiterable(obj): + try: + obj = iter(obj) + except: + return False + else: + return True + + +class PulpError(Exception): + """ + Pulp Exception Class + """ + + pass diff --git a/utils/pulp/mps_lp.py b/utils/pulp/mps_lp.py new file mode 100644 index 0000000..e5ef2be --- /dev/null +++ b/utils/pulp/mps_lp.py @@ -0,0 +1,401 @@ +""" + +@author: Franco Peschiera + +""" + +import re +from . import constants as const + +CORE_FILE_ROW_MODE = "ROWS" +CORE_FILE_COL_MODE = "COLUMNS" +CORE_FILE_RHS_MODE = "RHS" +CORE_FILE_BOUNDS_MODE = "BOUNDS" + +CORE_FILE_BOUNDS_MODE_NAME_GIVEN = "BOUNDS_NAME" +CORE_FILE_BOUNDS_MODE_NO_NAME = "BOUNDS_NO_NAME" +CORE_FILE_RHS_MODE_NAME_GIVEN = "RHS_NAME" +CORE_FILE_RHS_MODE_NO_NAME = "RHS_NO_NAME" + +ROW_MODE_OBJ = "N" + +BOUNDS_EQUIV = dict(LO="lowBound", UP="upBound") + +ROW_EQUIV = {v: k for k, v in const.LpConstraintTypeToMps.items()} +COL_EQUIV = {1: "Integer", 0: "Continuous"} +ROW_DEFAULT = dict(pi=None, constant=0) +COL_DEFAULT = dict(lowBound=0, upBound=None, varValue=None, dj=None) + + +def readMPS(path, sense, dropConsNames=False): + """ + adapted from Julian Märte (https://github.com/pchtsp/pysmps) + returns a dictionary with the contents of the model. + This dictionary can be used to generate an LpProblem + + :param path: path of mps file + :param sense: 1 for minimize, -1 for maximize + :param dropConsNames: if True, do not store the names of constraints + :return: a dictionary with all the problem data + """ + + mode = "" + parameters = dict(name="", sense=sense, status=0, sol_status=0) + variable_info = {} + constraints = {} + objective = dict(name="", coefficients=[]) + sos1 = [] + sos2 = [] + # TODO: maybe take out rhs_names and bnd_names? not sure if they're useful + rhs_names = [] + bnd_names = [] + integral_marker = False + + with open(path) as reader: + for line in reader: + line = re.split(" |\t", line) + line = [x.strip() for x in line] + line = list(filter(None, line)) + + if line[0] == "ENDATA": + break + if line[0] == "*": + continue + if line[0] == "NAME": + if len(line) > 1: + parameters["name"] = line[1] + else: + parameters["name"] = "" + continue + + # here we get the mode + if line[0] in [CORE_FILE_ROW_MODE, CORE_FILE_COL_MODE]: + mode = line[0] + elif line[0] == CORE_FILE_RHS_MODE and len(line) <= 2: + if len(line) > 1: + rhs_names.append(line[1]) + mode = CORE_FILE_RHS_MODE_NAME_GIVEN + else: + mode = CORE_FILE_RHS_MODE_NO_NAME + elif line[0] == CORE_FILE_BOUNDS_MODE and len(line) <= 2: + if len(line) > 1: + bnd_names.append(line[1]) + mode = CORE_FILE_BOUNDS_MODE_NAME_GIVEN + else: + mode = CORE_FILE_BOUNDS_MODE_NO_NAME + + # here we query the mode variable + elif mode == CORE_FILE_ROW_MODE: + row_type = line[0] + row_name = line[1] + if row_type == ROW_MODE_OBJ: + objective["name"] = row_name + else: + constraints[row_name] = dict( + sense=ROW_EQUIV[row_type], + name=row_name, + coefficients=[], + **ROW_DEFAULT, + ) + elif mode == CORE_FILE_COL_MODE: + var_name = line[0] + if len(line) > 1 and line[1] == "'MARKER'": + if line[2] == "'INTORG'": + integral_marker = True + elif line[2] == "'INTEND'": + integral_marker = False + continue + if var_name not in variable_info: + variable_info[var_name] = dict( + cat=COL_EQUIV[integral_marker], name=var_name, **COL_DEFAULT + ) + j = 1 + while j < len(line) - 1: + if line[j] == objective["name"]: + # we store the variable objective coefficient + objective["coefficients"].append( + dict(name=var_name, value=float(line[j + 1])) + ) + else: + # we store the variable coefficient + constraints[line[j]]["coefficients"].append( + dict(name=var_name, value=float(line[j + 1])) + ) + j = j + 2 + elif mode == CORE_FILE_RHS_MODE_NAME_GIVEN: + if line[0] != rhs_names[-1]: + raise Exception( + "Other RHS name was given even though name was set after RHS tag." + ) + readMPSSetRhs(line, constraints) + elif mode == CORE_FILE_RHS_MODE_NO_NAME: + readMPSSetRhs(line, constraints) + if line[0] not in rhs_names: + rhs_names.append(line[0]) + elif mode == CORE_FILE_BOUNDS_MODE_NAME_GIVEN: + if line[1] != bnd_names[-1]: + raise Exception( + "Other BOUNDS name was given even though name was set after BOUNDS tag." + ) + readMPSSetBounds(line, variable_info) + elif mode == CORE_FILE_BOUNDS_MODE_NO_NAME: + readMPSSetBounds(line, variable_info) + if line[1] not in bnd_names: + bnd_names.append(line[1]) + constraints = list(constraints.values()) + if dropConsNames: + for c in constraints: + c["name"] = None + objective["name"] = None + variable_info = list(variable_info.values()) + return dict( + parameters=parameters, + objective=objective, + variables=variable_info, + constraints=constraints, + sos1=sos1, + sos2=sos2, + ) + + +def readMPSSetBounds(line, variable_dict): + bound = line[0] + var_name = line[2] + + def set_one_bound(bound_type, value): + variable_dict[var_name][BOUNDS_EQUIV[bound_type]] = value + + def set_both_bounds(value_low, value_up): + set_one_bound("LO", value_low) + set_one_bound("UP", value_up) + + if bound == "FR": + set_both_bounds(None, None) + return + elif bound == "BV": + set_both_bounds(0, 1) + return + value = float(line[3]) + if bound in ["LO", "UP"]: + set_one_bound(bound, value) + elif bound == "FX": + set_both_bounds(value, value) + return + + +def readMPSSetRhs(line, constraintsDict): + constraintsDict[line[1]]["constant"] = -float(line[2]) + if len(line) == 5: # read fields 5, 6 + constraintsDict[line[3]]["constant"] = -float(line[4]) + return + + +def writeMPS( + LpProblem, filename, mpsSense=0, rename=0, mip=1, with_objsense: bool = False +): + wasNone, dummyVar = LpProblem.fixObjective() + if mpsSense == 0: + mpsSense = LpProblem.sense + cobj = LpProblem.objective + if mpsSense != LpProblem.sense: + n = cobj.name + cobj = -cobj + cobj.name = n + if rename: + constrNames, varNames, cobj.name = LpProblem.normalisedNames() + # No need to call self.variables() again, we have just filled self._variables: + vs = LpProblem._variables + else: + vs = LpProblem.variables() + varNames = {v.name: v.name for v in vs} + constrNames = {c: c for c in LpProblem.constraints} + model_name = LpProblem.name + if rename: + model_name = "MODEL" + objName = cobj.name + if not objName: + objName = "OBJ" + + # constraints + row_lines = [ + " " + const.LpConstraintTypeToMps[c.sense] + " " + constrNames[k] + "\n" + for k, c in LpProblem.constraints.items() + ] + # Creation of a dict of dict: + # coefs[variable_name][constraint_name] = coefficient + coefs = {varNames[v.name]: {} for v in vs} + for k, c in LpProblem.constraints.items(): + k = constrNames[k] + for v, value in c.items(): + coefs[varNames[v.name]][k] = value + + # matrix + columns_lines = [] + for v in vs: + name = varNames[v.name] + columns_lines.extend( + writeMPSColumnLines(coefs[name], v, mip, name, cobj, objName) + ) + + # right hand side + rhs_lines = [ + " RHS %-8s % .12e\n" + % (constrNames[k], -c.constant if c.constant != 0 else 0) + for k, c in LpProblem.constraints.items() + ] + # bounds + bound_lines = [] + for v in vs: + bound_lines.extend(writeMPSBoundLines(varNames[v.name], v, mip)) + + with open(filename, "w") as f: + if with_objsense: + f.write("OBJSENSE\n") + f.write(f" {const.LpSensesMPS[mpsSense]}\n") + else: + f.write(f"*SENSE:{const.LpSenses[mpsSense]}\n") + f.write(f"NAME {model_name}\n") + f.write("ROWS\n") + f.write(f" N {objName}\n") + f.write("".join(row_lines)) + f.write("COLUMNS\n") + f.write("".join(columns_lines)) + f.write("RHS\n") + f.write("".join(rhs_lines)) + f.write("BOUNDS\n") + f.write("".join(bound_lines)) + f.write("ENDATA\n") + LpProblem.restoreObjective(wasNone, dummyVar) + # returns the variables, in writing order + if rename == 0: + return vs + else: + return vs, varNames, constrNames, cobj.name + + +def writeMPSColumnLines(cv, variable, mip, name, cobj, objName): + columns_lines = [] + if mip and variable.cat == const.LpInteger: + columns_lines.append(" MARK 'MARKER' 'INTORG'\n") + # Most of the work is done here + _tmp = [" %-8s %-8s % .12e\n" % (name, k, v) for k, v in cv.items()] + columns_lines.extend(_tmp) + + # objective function + if variable in cobj: + columns_lines.append( + " %-8s %-8s % .12e\n" % (name, objName, cobj[variable]) + ) + if mip and variable.cat == const.LpInteger: + columns_lines.append(" MARK 'MARKER' 'INTEND'\n") + return columns_lines + + +def writeMPSBoundLines(name, variable, mip): + if variable.lowBound is not None and variable.lowBound == variable.upBound: + return [" FX BND %-8s % .12e\n" % (name, variable.lowBound)] + elif ( + variable.lowBound == 0 + and variable.upBound == 1 + and mip + and variable.cat == const.LpInteger + ): + return [" BV BND %-8s\n" % name] + bound_lines = [] + if variable.lowBound is not None: + # In MPS files, variables with no bounds (i.e. >= 0) + # are assumed BV by COIN and CPLEX. + # So we explicitly write a 0 lower bound in this case. + if variable.lowBound != 0 or ( + mip and variable.cat == const.LpInteger and variable.upBound is None + ): + bound_lines.append( + " LO BND %-8s % .12e\n" % (name, variable.lowBound) + ) + else: + if variable.upBound is not None: + bound_lines.append(" MI BND %-8s\n" % name) + else: + bound_lines.append(" FR BND %-8s\n" % name) + if variable.upBound is not None: + bound_lines.append(" UP BND %-8s % .12e\n" % (name, variable.upBound)) + return bound_lines + + +def writeLP(LpProblem, filename, writeSOS=1, mip=1, max_length=100): + f = open(filename, "w") + f.write("\\* " + LpProblem.name + " *\\\n") + if LpProblem.sense == 1: + f.write("Minimize\n") + else: + f.write("Maximize\n") + wasNone, objectiveDummyVar = LpProblem.fixObjective() + objName = LpProblem.objective.name + if not objName: + objName = "OBJ" + f.write(LpProblem.objective.asCplexLpAffineExpression(objName, constant=0)) + f.write("Subject To\n") + ks = list(LpProblem.constraints.keys()) + ks.sort() + dummyWritten = False + for k in ks: + constraint = LpProblem.constraints[k] + if not list(constraint.keys()): + # empty constraint add the dummyVar + dummyVar = LpProblem.get_dummyVar() + constraint += dummyVar + # set this dummyvar to zero so infeasible problems are not made feasible + if not dummyWritten: + f.write((dummyVar == 0.0).asCplexLpConstraint("_dummy")) + dummyWritten = True + f.write(constraint.asCplexLpConstraint(k)) + # check if any names are longer than 100 characters + LpProblem.checkLengthVars(max_length) + vs = LpProblem.variables() + # check for repeated names + LpProblem.checkDuplicateVars() + # Bounds on non-"positive" variables + # Note: XPRESS and CPLEX do not interpret integer variables without + # explicit bounds + if mip: + vg = [ + v + for v in vs + if not (v.isPositive() and v.cat == const.LpContinuous) and not v.isBinary() + ] + else: + vg = [v for v in vs if not v.isPositive()] + if vg: + f.write("Bounds\n") + for v in vg: + f.write(f" {v.asCplexLpVariable()}\n") + # Integer non-binary variables + if mip: + vg = [v for v in vs if v.cat == const.LpInteger and not v.isBinary()] + if vg: + f.write("Generals\n") + for v in vg: + f.write(f"{v.name}\n") + # Binary variables + vg = [v for v in vs if v.isBinary()] + if vg: + f.write("Binaries\n") + for v in vg: + f.write(f"{v.name}\n") + # Special Ordered Sets + if writeSOS and (LpProblem.sos1 or LpProblem.sos2): + f.write("SOS\n") + if LpProblem.sos1: + for sos in LpProblem.sos1.values(): + f.write("S1:: \n") + for v, val in sos.items(): + f.write(f" {v.name}: {val:.12g}\n") + if LpProblem.sos2: + for sos in LpProblem.sos2.values(): + f.write("S2:: \n") + for v, val in sos.items(): + f.write(f" {v.name}: {val:.12g}\n") + f.write("End\n") + f.close() + LpProblem.restoreObjective(wasNone, objectiveDummyVar) + return vs diff --git a/utils/pulp/pulp.cfg.buildout b/utils/pulp/pulp.cfg.buildout new file mode 100644 index 0000000..dcadc05 --- /dev/null +++ b/utils/pulp/pulp.cfg.buildout @@ -0,0 +1,15 @@ +# The configuration file that holds locations for 3rd party solvers +# This is an appropriate configuration file for linux uses and in this case is assuming that the +# needed libraries are in the same directory as the config file (note this is not ideal and +# may change in later versions) +# Libraries are ordered in the needed order to resolve dependancies and CoinMP is loaded last +# a windows specific configuation file is pulp.cfg.win +#$Id: pulp.cfg 1704 2007-12-20 21:56:14Z smit023 $ +[locations] +CoinMPPath = %(here)s/../../parts/coinMP/lib/libCoinUtils.so, %(here)s/../../parts/coinMP/lib/libClp.so, %(here)s/../../parts/coinMP/lib/libOsi.so, %(here)s/../../parts/coinMP/lib/libOsiClp.so, %(here)s/../../parts/coinMP/lib/libCgl.so, %(here)s/../../parts/coinMP/lib/libCbc.so, %(here)s/../../parts/coinMP/lib/libOsiCbc.so, %(here)s/../../parts/coinMP/lib/libCbcSolver.so, %(here)s/../../parts/coinMP/lib/libCoinMP.so +CplexPath = /usr/ilog/cplex/bin/x86_rhel4.0_3.4/libcplex110.so +#note the recommended gurobi changes must be made to your PATH +#and LD_LIBRARY_PATH enviroment variables +GurobiPath = /opt/gurobi201/linux32/lib/python2.5 +CbcPath = %(here)s/../../parts/cbc/bin/cbc +GlpkPath = %(here)s/../../parts/glpk/bin/glpsol diff --git a/utils/pulp/pulp.cfg.win b/utils/pulp/pulp.cfg.win new file mode 100644 index 0000000..37ff7a2 --- /dev/null +++ b/utils/pulp/pulp.cfg.win @@ -0,0 +1,18 @@ +# The configuration file that holds locations for 3rd party solvers +# for CoinMp.dll it is assumed that no location will place the file in the +# same directory as the configuration file. (note this is not ideal and +# may change in later versions) +# a linux file is provided in pulp.cfg.linux +#$Id: pulp.cfg 1704 2007-12-20 21:56:14Z smit023 $ +[locations] +CoinMPPath = %(here)s\solverdir\coinMP\%(os)s\%(arch)s\CoinMP.dll +CplexPath = cplex100.dll +#must have gurobi.dll somewhere on the search path +GurobiPath = C:\gurobi10\win32\python2.5\lib +CbcPath = cbc +GlpkPath = glpsol +PulpCbcPath = %(here)s\solverdir\cbc\%(os)s\%(arch)s\cbc.exe +PulpChocoPath = %(here)s/solverdir/choco/choco-parsers-with-dependencies.jar +[licenses] +ilm_cplex_license = "LICENSE your-enterprise\nRUNTIME NEVER ..." +ilm_cplex_license_signature = 0 diff --git a/utils/pulp/pulp.py b/utils/pulp/pulp.py new file mode 100644 index 0000000..a1a2b40 --- /dev/null +++ b/utils/pulp/pulp.py @@ -0,0 +1,2298 @@ +#! /usr/bin/env python +# PuLP : Python LP Modeler + + +# Copyright (c) 2002-2005, Jean-Sebastien Roy (js@jeannot.org) +# Modifications Copyright (c) 2007- Stuart Anthony Mitchell (s.mitchell@auckland.ac.nz) +# $Id: pulp.py 1791 2008-04-23 22:54:34Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +PuLP is an LP modeler written in python. PuLP can generate MPS or LP files +and call GLPK[1], COIN CLP/CBC[2], CPLEX[3], GUROBI[4] and MOSEK[5] to solve linear +problems. + +See the examples directory for examples. + +The examples require at least a solver in your PATH or a shared library file. + +Documentation is found on https://www.coin-or.org/PuLP/. +A comprehensive wiki can be found at https://www.coin-or.org/PuLP/ + +Use LpVariable() to create new variables. To create a variable 0 <= x <= 3 +>>> x = LpVariable("x", 0, 3) + +To create a variable 0 <= y <= 1 +>>> y = LpVariable("y", 0, 1) + +Use LpProblem() to create new problems. Create "myProblem" +>>> prob = LpProblem("myProblem", const.LpMinimize) + +Combine variables to create expressions and constraints and add them to the +problem. +>>> prob += x + y <= 2 + +If you add an expression (not a constraint), it will +become the objective. +>>> prob += -4 * x + y + +Choose a solver and solve the problem. ex: +>>> status = prob.solve(PULP_CBC_CMD(msg=0)) + +Display the status of the solution +>>> const.LpStatus[status] +'Optimal' + +You can get the value of the variables using value(). ex: +>>> value(x) +2.0 + +Exported Classes: + - LpProblem -- Container class for a Linear programming problem + - LpVariable -- Variables that are added to constraints in the LP + - LpConstraint -- A constraint of the general form + a1x1+a2x2 ...anxn (<=, =, >=) b + - LpConstraintVar -- Used to construct a column of the model in column-wise + modelling + +Exported Functions: + - value() -- Finds the value of a variable or expression + - lpSum() -- given a list of the form [a1*x1, a2x2, ..., anxn] will construct + a linear expression to be used as a constraint or variable + - lpDot() --given two lists of the form [a1, a2, ..., an] and + [ x1, x2, ..., xn] will construct a linear epression to be used + as a constraint or variable + +Comments, bug reports, patches and suggestions are welcome. +https://github.com/coin-or/pulp + +References: +[1] http://www.gnu.org/software/glpk/glpk.html +[2] http://www.coin-or.org/ +[3] http://www.cplex.com/ +[4] http://www.gurobi.com/ +[5] http://www.mosek.com/ +""" + +from collections import Counter +import sys +import warnings +from time import time + +from .apis import LpSolverDefault, PULP_CBC_CMD +from .apis.core import clock +from .utilities import value +from . import constants as const +from . import mps_lp as mpslp + +try: + from collections.abc import Iterable +except ImportError: + # python 2.7 compatible + from collections.abc import Iterable + +import logging + +log = logging.getLogger(__name__) + +try: # allow Python 2/3 compatibility + maketrans = str.maketrans +except AttributeError: + from string import maketrans + +_DICT_TYPE = dict + +if sys.platform not in ["cli"]: + # iron python does not like an OrderedDict + try: + from odict import OrderedDict + + _DICT_TYPE = OrderedDict + except ImportError: + pass + try: + # python 2.7 or 3.1 + from collections import OrderedDict + + _DICT_TYPE = OrderedDict + except ImportError: + pass + +try: + import ujson as json +except ImportError: + import json + +import re + + +class LpElement: + """Base class for LpVariable and LpConstraintVar""" + + # To remove illegal characters from the names + illegal_chars = "-+[] ->/" + expression = re.compile(f"[{re.escape(illegal_chars)}]") + trans = maketrans(illegal_chars, "________") + + def setName(self, name): + if name: + if self.expression.match(name): + warnings.warn( + "The name {} has illegal characters that will be replaced by _".format( + name + ) + ) + self.__name = str(name).translate(self.trans) + else: + self.__name = None + + def getName(self): + return self.__name + + name = property(fget=getName, fset=setName) + + def __init__(self, name): + self.name = name + # self.hash MUST be different for each variable + # else dict() will call the comparison operators that are overloaded + self.hash = id(self) + self.modified = True + + def __hash__(self): + return self.hash + + def __str__(self): + return self.name + + def __repr__(self): + return self.name + + def __neg__(self): + return -LpAffineExpression(self) + + def __pos__(self): + return self + + def __bool__(self): + return True + + def __add__(self, other): + return LpAffineExpression(self) + other + + def __radd__(self, other): + return LpAffineExpression(self) + other + + def __sub__(self, other): + return LpAffineExpression(self) - other + + def __rsub__(self, other): + return other - LpAffineExpression(self) + + def __mul__(self, other): + return LpAffineExpression(self) * other + + def __rmul__(self, other): + return LpAffineExpression(self) * other + + def __div__(self, other): + return LpAffineExpression(self) / other + + def __rdiv__(self, other): + raise TypeError("Expressions cannot be divided by a variable") + + def __le__(self, other): + return LpAffineExpression(self) <= other + + def __ge__(self, other): + return LpAffineExpression(self) >= other + + def __eq__(self, other): + return LpAffineExpression(self) == other + + def __ne__(self, other): + if isinstance(other, LpVariable): + return self.name is not other.name + elif isinstance(other, LpAffineExpression): + if other.isAtomic(): + return self is not other.atom() + else: + return 1 + else: + return 1 + + +class LpVariable(LpElement): + """ + This class models an LP Variable with the specified associated parameters + + :param name: The name of the variable used in the output .lp file + :param lowBound: The lower bound on this variable's range. + Default is negative infinity + :param upBound: The upper bound on this variable's range. + Default is positive infinity + :param cat: The category this variable is in, Integer, Binary or + Continuous(default) + :param e: Used for column based modelling: relates to the variable's + existence in the objective function and constraints + """ + + def __init__( + self, name, lowBound=None, upBound=None, cat=const.LpContinuous, e=None + ): + LpElement.__init__(self, name) + self._lowbound_original = self.lowBound = lowBound + self._upbound_original = self.upBound = upBound + self.cat = cat + self.varValue = None + self.dj = None + if cat == const.LpBinary: + self._lowbound_original = self.lowBound = 0 + self._upbound_original = self.upBound = 1 + self.cat = const.LpInteger + # Code to add a variable to constraints for column based + # modelling. + if e: + self.add_expression(e) + + def toDict(self): + """ + Exports a variable into a dictionary with its relevant information + + :return: a dictionary with the variable information + :rtype: dict + """ + return dict( + lowBound=self.lowBound, + upBound=self.upBound, + cat=self.cat, + varValue=self.varValue, + dj=self.dj, + name=self.name, + ) + + to_dict = toDict + + @classmethod + def fromDict(cls, dj=None, varValue=None, **kwargs): + """ + Initializes a variable object from information that comes from a dictionary (kwargs) + + :param dj: shadow price of the variable + :param float varValue: the value to set the variable + :param kwargs: arguments to initialize the variable + :return: a :py:class:`LpVariable` + :rtype: :LpVariable + """ + var = cls(**kwargs) + var.dj = dj + var.varValue = varValue + return var + + from_dict = fromDict + + def add_expression(self, e): + self.expression = e + self.addVariableToConstraints(e) + + @classmethod + def matrix( + cls, + name, + indices=None, # required param. enforced within function for backwards compatibility + lowBound=None, + upBound=None, + cat=const.LpContinuous, + indexStart=[], + indexs=None, + ): + # Backwards Compatiblity with Deprecation Warning for indexs + if indices is not None and indexs is not None: + raise TypeError( + "Both 'indices' and 'indexs' provided to LpVariable.matrix. Use one only, preferably 'indices'." + ) + elif indices is not None: + pass + elif indexs is not None: + warnings.warn( + "'indexs' is deprecated; use 'indices'.", DeprecationWarning, 2 + ) + indices = indexs + else: + raise TypeError( + "LpVariable.matrix missing both 'indices' and deprecated 'indexs' arguments." + ) + + if not isinstance(indices, tuple): + indices = (indices,) + if "%" not in name: + name += "_%s" * len(indices) + + index = indices[0] + indices = indices[1:] + if len(indices) == 0: + return [ + LpVariable(name % tuple(indexStart + [i]), lowBound, upBound, cat) + for i in index + ] + else: + return [ + LpVariable.matrix( + name, indices, lowBound, upBound, cat, indexStart + [i] + ) + for i in index + ] + + @classmethod + def dicts( + cls, + name, + indices=None, # required param. enforced within function for backwards compatibility + lowBound=None, + upBound=None, + cat=const.LpContinuous, + indexStart=[], + indexs=None, + ): + """ + This function creates a dictionary of :py:class:`LpVariable` with the specified associated parameters. + + :param name: The prefix to the name of each LP variable created + :param indices: A list of strings of the keys to the dictionary of LP + variables, and the main part of the variable name itself + :param lowBound: The lower bound on these variables' range. Default is + negative infinity + :param upBound: The upper bound on these variables' range. Default is + positive infinity + :param cat: The category these variables are in, Integer or + Continuous(default) + :param indexs: (deprecated) Replaced with `indices` parameter + + :return: A dictionary of :py:class:`LpVariable` + """ + + # Backwards Compatiblity with Deprecation Warning for indexs + if indices is not None and indexs is not None: + raise TypeError( + "Both 'indices' and 'indexs' provided to LpVariable.dicts. Use one only, preferably 'indices'." + ) + elif indices is not None: + pass + elif indexs is not None: + warnings.warn( + "'indexs' is deprecated; use 'indices'.", DeprecationWarning, 2 + ) + indices = indexs + else: + raise TypeError( + "LpVariable.dicts missing both 'indices' and deprecated 'indexs' arguments." + ) + + if not isinstance(indices, tuple): + indices = (indices,) + if "%" not in name: + name += "_%s" * len(indices) + + index = indices[0] + indices = indices[1:] + d = {} + if len(indices) == 0: + for i in index: + d[i] = LpVariable( + name % tuple(indexStart + [str(i)]), lowBound, upBound, cat + ) + else: + for i in index: + d[i] = LpVariable.dicts( + name, indices, lowBound, upBound, cat, indexStart + [i] + ) + return d + + @classmethod + def dict(cls, name, indices, lowBound=None, upBound=None, cat=const.LpContinuous): + if not isinstance(indices, tuple): + indices = (indices,) + if "%" not in name: + name += "_%s" * len(indices) + + lists = indices + + if len(indices) > 1: + # Cartesian product + res = [] + while len(lists): + first = lists[-1] + nres = [] + if res: + if first: + for f in first: + nres.extend([[f] + r for r in res]) + else: + nres = res + res = nres + else: + res = [[f] for f in first] + lists = lists[:-1] + index = [tuple(r) for r in res] + elif len(indices) == 1: + index = indices[0] + else: + return {} + + d = {} + for i in index: + d[i] = cls(name % i, lowBound, upBound, cat) + return d + + def getLb(self): + return self.lowBound + + def getUb(self): + return self.upBound + + def bounds(self, low, up): + self.lowBound = low + self.upBound = up + self.modified = True + + def positive(self): + self.bounds(0, None) + + def value(self): + return self.varValue + + def round(self, epsInt=1e-5, eps=1e-7): + if self.varValue is not None: + if ( + self.upBound != None + and self.varValue > self.upBound + and self.varValue <= self.upBound + eps + ): + self.varValue = self.upBound + elif ( + self.lowBound != None + and self.varValue < self.lowBound + and self.varValue >= self.lowBound - eps + ): + self.varValue = self.lowBound + if ( + self.cat == const.LpInteger + and abs(round(self.varValue) - self.varValue) <= epsInt + ): + self.varValue = round(self.varValue) + + def roundedValue(self, eps=1e-5): + if ( + self.cat == const.LpInteger + and self.varValue != None + and abs(self.varValue - round(self.varValue)) <= eps + ): + return round(self.varValue) + else: + return self.varValue + + def valueOrDefault(self): + if self.varValue != None: + return self.varValue + elif self.lowBound != None: + if self.upBound != None: + if 0 >= self.lowBound and 0 <= self.upBound: + return 0 + else: + if self.lowBound >= 0: + return self.lowBound + else: + return self.upBound + else: + if 0 >= self.lowBound: + return 0 + else: + return self.lowBound + elif self.upBound != None: + if 0 <= self.upBound: + return 0 + else: + return self.upBound + else: + return 0 + + def valid(self, eps): + if self.name == "__dummy" and self.varValue is None: + return True + if self.varValue is None: + return False + if self.upBound is not None and self.varValue > self.upBound + eps: + return False + if self.lowBound is not None and self.varValue < self.lowBound - eps: + return False + if ( + self.cat == const.LpInteger + and abs(round(self.varValue) - self.varValue) > eps + ): + return False + return True + + def infeasibilityGap(self, mip=1): + if self.varValue == None: + raise ValueError("variable value is None") + if self.upBound != None and self.varValue > self.upBound: + return self.varValue - self.upBound + if self.lowBound != None and self.varValue < self.lowBound: + return self.varValue - self.lowBound + if ( + mip + and self.cat == const.LpInteger + and round(self.varValue) - self.varValue != 0 + ): + return round(self.varValue) - self.varValue + return 0 + + def isBinary(self): + return self.cat == const.LpInteger and self.lowBound == 0 and self.upBound == 1 + + def isInteger(self): + return self.cat == const.LpInteger + + def isFree(self): + return self.lowBound is None and self.upBound is None + + def isConstant(self): + return self.lowBound is not None and self.upBound == self.lowBound + + def isPositive(self): + return self.lowBound == 0 and self.upBound is None + + def asCplexLpVariable(self): + if self.isFree(): + return self.name + " free" + if self.isConstant(): + return self.name + f" = {self.lowBound:.12g}" + if self.lowBound == None: + s = "-inf <= " + # Note: XPRESS and CPLEX do not interpret integer variables without + # explicit bounds + elif self.lowBound == 0 and self.cat == const.LpContinuous: + s = "" + else: + s = f"{self.lowBound:.12g} <= " + s += self.name + if self.upBound is not None: + s += f" <= {self.upBound:.12g}" + return s + + def asCplexLpAffineExpression(self, name, constant=1): + return LpAffineExpression(self).asCplexLpAffineExpression(name, constant) + + def __ne__(self, other): + if isinstance(other, LpElement): + return self.name is not other.name + elif isinstance(other, LpAffineExpression): + if other.isAtomic(): + return self is not other.atom() + else: + return 1 + else: + return 1 + + def __bool__(self): + return bool(self.roundedValue()) + + def addVariableToConstraints(self, e): + """adds a variable to the constraints indicated by + the LpConstraintVars in e + """ + for constraint, coeff in e.items(): + constraint.addVariable(self, coeff) + + def setInitialValue(self, val, check=True): + """ + sets the initial value of the variable to `val` + May be used for warmStart a solver, if supported by the solver + + :param float val: value to set to variable + :param bool check: if True, we check if the value fits inside the variable bounds + :return: True if the value was set + :raises ValueError: if check=True and the value does not fit inside the bounds + """ + lb = self.lowBound + ub = self.upBound + config = [ + ("smaller", "lowBound", lb, lambda: val < lb), + ("greater", "upBound", ub, lambda: val > ub), + ] + + for rel, bound_name, bound_value, condition in config: + if bound_value is not None and condition(): + if not check: + return False + raise ValueError( + "In variable {}, initial value {} is {} than {} {}".format( + self.name, val, rel, bound_name, bound_value + ) + ) + self.varValue = val + return True + + def fixValue(self): + """ + changes lower bound and upper bound to the initial value if exists. + :return: None + """ + val = self.varValue + if val is not None: + self.bounds(val, val) + + def isFixed(self): + """ + + :return: True if upBound and lowBound are the same + :rtype: bool + """ + return self.isConstant() + + def unfixValue(self): + self.bounds(self._lowbound_original, self._upbound_original) + + +class LpAffineExpression(_DICT_TYPE): + """ + A linear combination of :class:`LpVariables`. + Can be initialised with the following: + + #. e = None: an empty Expression + #. e = dict: gives an expression with the values being the coefficients of the keys (order of terms is undetermined) + #. e = list or generator of 2-tuples: equivalent to dict.items() + #. e = LpElement: an expression of length 1 with the coefficient 1 + #. e = other: the constant is initialised as e + + Examples: + + >>> f=LpAffineExpression(LpElement('x')) + >>> f + 1*x + 0 + >>> x_name = ['x_0', 'x_1', 'x_2'] + >>> x = [LpVariable(x_name[i], lowBound = 0, upBound = 10) for i in range(3) ] + >>> c = LpAffineExpression([ (x[0],1), (x[1],-3), (x[2],4)]) + >>> c + 1*x_0 + -3*x_1 + 4*x_2 + 0 + """ + + # to remove illegal characters from the names + trans = maketrans("-+[] ", "_____") + + def setName(self, name): + if name: + self.__name = str(name).translate(self.trans) + else: + self.__name = None + + def getName(self): + return self.__name + + name = property(fget=getName, fset=setName) + + def __init__(self, e=None, constant=0, name=None): + self.name = name + # TODO remove isinstance usage + if e is None: + e = {} + if isinstance(e, LpAffineExpression): + # Will not copy the name + self.constant = e.constant + super().__init__(list(e.items())) + elif isinstance(e, dict): + self.constant = constant + super().__init__(list(e.items())) + elif isinstance(e, Iterable): + self.constant = constant + super().__init__(e) + elif isinstance(e, LpElement): + self.constant = 0 + super().__init__([(e, 1)]) + else: + self.constant = e + super().__init__() + + # Proxy functions for variables + + def isAtomic(self): + return len(self) == 1 and self.constant == 0 and list(self.values())[0] == 1 + + def isNumericalConstant(self): + return len(self) == 0 + + def atom(self): + return list(self.keys())[0] + + # Functions on expressions + + def __bool__(self): + return (float(self.constant) != 0.0) or (len(self) > 0) + + def value(self): + s = self.constant + for v, x in self.items(): + if v.varValue is None: + return None + s += v.varValue * x + return s + + def valueOrDefault(self): + s = self.constant + for v, x in self.items(): + s += v.valueOrDefault() * x + return s + + def addterm(self, key, value): + y = self.get(key, 0) + if y: + y += value + self[key] = y + else: + self[key] = value + + def emptyCopy(self): + return LpAffineExpression() + + def copy(self): + """Make a copy of self except the name which is reset""" + # Will not copy the name + return LpAffineExpression(self) + + def __str__(self, constant=1): + s = "" + for v in self.sorted_keys(): + val = self[v] + if val < 0: + if s != "": + s += " - " + else: + s += "-" + val = -val + elif s != "": + s += " + " + if val == 1: + s += str(v) + else: + s += str(val) + "*" + str(v) + if constant: + if s == "": + s = str(self.constant) + else: + if self.constant < 0: + s += " - " + str(-self.constant) + elif self.constant > 0: + s += " + " + str(self.constant) + elif s == "": + s = "0" + return s + + def sorted_keys(self): + """ + returns the list of keys sorted by name + """ + result = [(v.name, v) for v in self.keys()] + result.sort() + result = [v for _, v in result] + return result + + def __repr__(self): + l = [str(self[v]) + "*" + str(v) for v in self.sorted_keys()] + l.append(str(self.constant)) + s = " + ".join(l) + return s + + @staticmethod + def _count_characters(line): + # counts the characters in a list of strings + return sum(len(t) for t in line) + + def asCplexVariablesOnly(self, name): + """ + helper for asCplexLpAffineExpression + """ + result = [] + line = [f"{name}:"] + notFirst = 0 + variables = self.sorted_keys() + for v in variables: + val = self[v] + if val < 0: + sign = " -" + val = -val + elif notFirst: + sign = " +" + else: + sign = "" + notFirst = 1 + if val == 1: + term = f"{sign} {v.name}" + else: + # adding zero to val to remove instances of negative zero + term = f"{sign} {val + 0:.12g} {v.name}" + + if self._count_characters(line) + len(term) > const.LpCplexLPLineSize: + result += ["".join(line)] + line = [term] + else: + line += [term] + return result, line + + def asCplexLpAffineExpression(self, name, constant=1): + """ + returns a string that represents the Affine Expression in lp format + """ + # refactored to use a list for speed in iron python + result, line = self.asCplexVariablesOnly(name) + if not self: + term = f" {self.constant}" + else: + term = "" + if constant: + if self.constant < 0: + term = " - %s" % (-self.constant) + elif self.constant > 0: + term = f" + {self.constant}" + if self._count_characters(line) + len(term) > const.LpCplexLPLineSize: + result += ["".join(line)] + line = [term] + else: + line += [term] + result += ["".join(line)] + result = "%s\n" % "\n".join(result) + return result + + def addInPlace(self, other): + if isinstance(other, int) and (other == 0): + return self + if other is None: + return self + if isinstance(other, LpElement): + self.addterm(other, 1) + elif isinstance(other, LpAffineExpression): + self.constant += other.constant + for v, x in other.items(): + self.addterm(v, x) + elif isinstance(other, dict): + for e in other.values(): + self.addInPlace(e) + elif isinstance(other, list) or isinstance(other, Iterable): + for e in other: + self.addInPlace(e) + else: + self.constant += other + return self + + def subInPlace(self, other): + if isinstance(other, int) and (other == 0): + return self + if other is None: + return self + if isinstance(other, LpElement): + self.addterm(other, -1) + elif isinstance(other, LpAffineExpression): + self.constant -= other.constant + for v, x in other.items(): + self.addterm(v, -x) + elif isinstance(other, dict): + for e in other.values(): + self.subInPlace(e) + elif isinstance(other, list) or isinstance(other, Iterable): + for e in other: + self.subInPlace(e) + else: + self.constant -= other + return self + + def __neg__(self): + e = self.emptyCopy() + e.constant = -self.constant + for v, x in self.items(): + e[v] = -x + return e + + def __pos__(self): + return self + + def __add__(self, other): + return self.copy().addInPlace(other) + + def __radd__(self, other): + return self.copy().addInPlace(other) + + def __iadd__(self, other): + return self.addInPlace(other) + + def __sub__(self, other): + return self.copy().subInPlace(other) + + def __rsub__(self, other): + return (-self).addInPlace(other) + + def __isub__(self, other): + return (self).subInPlace(other) + + def __mul__(self, other): + e = self.emptyCopy() + if isinstance(other, LpAffineExpression): + e.constant = self.constant * other.constant + if len(other): + if len(self): + raise TypeError("Non-constant expressions cannot be multiplied") + else: + c = self.constant + if c != 0: + for v, x in other.items(): + e[v] = c * x + else: + c = other.constant + if c != 0: + for v, x in self.items(): + e[v] = c * x + elif isinstance(other, LpVariable): + return self * LpAffineExpression(other) + else: + if other != 0: + e.constant = self.constant * other + for v, x in self.items(): + e[v] = other * x + return e + + def __rmul__(self, other): + return self * other + + def __div__(self, other): + if isinstance(other, LpAffineExpression) or isinstance(other, LpVariable): + if len(other): + raise TypeError( + "Expressions cannot be divided by a non-constant expression" + ) + other = other.constant + e = self.emptyCopy() + e.constant = self.constant / other + for v, x in self.items(): + e[v] = x / other + return e + + def __truediv__(self, other): + if isinstance(other, LpAffineExpression) or isinstance(other, LpVariable): + if len(other): + raise TypeError( + "Expressions cannot be divided by a non-constant expression" + ) + other = other.constant + e = self.emptyCopy() + e.constant = self.constant / other + for v, x in self.items(): + e[v] = x / other + return e + + def __rdiv__(self, other): + e = self.emptyCopy() + if len(self): + raise TypeError( + "Expressions cannot be divided by a non-constant expression" + ) + c = self.constant + if isinstance(other, LpAffineExpression): + e.constant = other.constant / c + for v, x in other.items(): + e[v] = x / c + else: + e.constant = other / c + return e + + def __le__(self, other): + return LpConstraint(self - other, const.LpConstraintLE) + + def __ge__(self, other): + return LpConstraint(self - other, const.LpConstraintGE) + + def __eq__(self, other): + return LpConstraint(self - other, const.LpConstraintEQ) + + def toDict(self): + """ + exports the :py:class:`LpAffineExpression` into a list of dictionaries with the coefficients + it does not export the constant + + :return: list of dictionaries with the coefficients + :rtype: list + """ + return [dict(name=k.name, value=v) for k, v in self.items()] + + to_dict = toDict + + +class LpConstraint(LpAffineExpression): + """An LP constraint""" + + def __init__(self, e=None, sense=const.LpConstraintEQ, name=None, rhs=None): + """ + :param e: an instance of :class:`LpAffineExpression` + :param sense: one of :data:`~pulp.const.LpConstraintEQ`, :data:`~pulp.const.LpConstraintGE`, :data:`~pulp.const.LpConstraintLE` (0, 1, -1 respectively) + :param name: identifying string + :param rhs: numerical value of constraint target + """ + LpAffineExpression.__init__(self, e, name=name) + if rhs is not None: + self.constant -= rhs + self.sense = sense + self.pi = None + self.slack = None + self.modified = True + + def getLb(self): + if (self.sense == const.LpConstraintGE) or (self.sense == const.LpConstraintEQ): + return -self.constant + else: + return None + + def getUb(self): + if (self.sense == const.LpConstraintLE) or (self.sense == const.LpConstraintEQ): + return -self.constant + else: + return None + + def __str__(self): + s = LpAffineExpression.__str__(self, 0) + if self.sense is not None: + s += " " + const.LpConstraintSenses[self.sense] + " " + str(-self.constant) + return s + + def asCplexLpConstraint(self, name): + """ + Returns a constraint as a string + """ + result, line = self.asCplexVariablesOnly(name) + if not list(self.keys()): + line += ["0"] + c = -self.constant + if c == 0: + c = 0 # Supress sign + term = f" {const.LpConstraintSenses[self.sense]} {c:.12g}" + if self._count_characters(line) + len(term) > const.LpCplexLPLineSize: + result += ["".join(line)] + line = [term] + else: + line += [term] + result += ["".join(line)] + result = "%s\n" % "\n".join(result) + return result + + def changeRHS(self, RHS): + """ + alters the RHS of a constraint so that it can be modified in a resolve + """ + self.constant = -RHS + self.modified = True + + def __repr__(self): + s = LpAffineExpression.__repr__(self) + if self.sense is not None: + s += " " + const.LpConstraintSenses[self.sense] + " 0" + return s + + def copy(self): + """Make a copy of self""" + return LpConstraint(self, self.sense) + + def emptyCopy(self): + return LpConstraint(sense=self.sense) + + def addInPlace(self, other): + if isinstance(other, LpConstraint): + if self.sense * other.sense >= 0: + LpAffineExpression.addInPlace(self, other) + self.sense |= other.sense + else: + LpAffineExpression.subInPlace(self, other) + self.sense |= -other.sense + elif isinstance(other, list): + for e in other: + self.addInPlace(e) + else: + LpAffineExpression.addInPlace(self, other) + # raise TypeError, "Constraints and Expressions cannot be added" + return self + + def subInPlace(self, other): + if isinstance(other, LpConstraint): + if self.sense * other.sense <= 0: + LpAffineExpression.subInPlace(self, other) + self.sense |= -other.sense + else: + LpAffineExpression.addInPlace(self, other) + self.sense |= other.sense + elif isinstance(other, list): + for e in other: + self.subInPlace(e) + else: + LpAffineExpression.subInPlace(self, other) + # raise TypeError, "Constraints and Expressions cannot be added" + return self + + def __neg__(self): + c = LpAffineExpression.__neg__(self) + c.sense = -c.sense + return c + + def __add__(self, other): + return self.copy().addInPlace(other) + + def __radd__(self, other): + return self.copy().addInPlace(other) + + def __sub__(self, other): + return self.copy().subInPlace(other) + + def __rsub__(self, other): + return (-self).addInPlace(other) + + def __mul__(self, other): + if isinstance(other, LpConstraint): + c = LpAffineExpression.__mul__(self, other) + if c.sense == 0: + c.sense = other.sense + elif other.sense != 0: + c.sense *= other.sense + return c + else: + return LpAffineExpression.__mul__(self, other) + + def __rmul__(self, other): + return self * other + + def __div__(self, other): + if isinstance(other, LpConstraint): + c = LpAffineExpression.__div__(self, other) + if c.sense == 0: + c.sense = other.sense + elif other.sense != 0: + c.sense *= other.sense + return c + else: + return LpAffineExpression.__mul__(self, other) + + def __rdiv__(self, other): + if isinstance(other, LpConstraint): + c = LpAffineExpression.__rdiv__(self, other) + if c.sense == 0: + c.sense = other.sense + elif other.sense != 0: + c.sense *= other.sense + return c + else: + return LpAffineExpression.__mul__(self, other) + + def valid(self, eps=0): + val = self.value() + if self.sense == const.LpConstraintEQ: + return abs(val) <= eps + else: + return val * self.sense >= -eps + + def makeElasticSubProblem(self, *args, **kwargs): + """ + Builds an elastic subproblem by adding variables to a hard constraint + + uses FixedElasticSubProblem + """ + return FixedElasticSubProblem(self, *args, **kwargs) + + def toDict(self): + """ + exports constraint information into a dictionary + + :return: dictionary with all the constraint information + """ + return dict( + sense=self.sense, + pi=self.pi, + constant=self.constant, + name=self.name, + coefficients=LpAffineExpression.toDict(self), + ) + + @classmethod + def fromDict(cls, _dict): + """ + Initializes a constraint object from a dictionary with necessary information + + :param dict _dict: dictionary with data + :return: a new :py:class:`LpConstraint` + """ + const = cls( + e=_dict["coefficients"], + rhs=-_dict["constant"], + name=_dict["name"], + sense=_dict["sense"], + ) + const.pi = _dict["pi"] + return const + + from_dict = fromDict + + +class LpFractionConstraint(LpConstraint): + """ + Creates a constraint that enforces a fraction requirement a/b = c + """ + + def __init__( + self, + numerator, + denominator=None, + sense=const.LpConstraintEQ, + RHS=1.0, + name=None, + complement=None, + ): + """ + creates a fraction Constraint to model constraints of + the nature + numerator/denominator {==, >=, <=} RHS + numerator/(numerator + complement) {==, >=, <=} RHS + + :param numerator: the top of the fraction + :param denominator: as described above + :param sense: the sense of the relation of the constraint + :param RHS: the target fraction value + :param complement: as described above + """ + self.numerator = numerator + if denominator is None and complement is not None: + self.complement = complement + self.denominator = numerator + complement + elif denominator is not None and complement is None: + self.denominator = denominator + self.complement = denominator - numerator + else: + self.denominator = denominator + self.complement = complement + lhs = self.numerator - RHS * self.denominator + LpConstraint.__init__(self, lhs, sense=sense, rhs=0, name=name) + self.RHS = RHS + + def findLHSValue(self): + """ + Determines the value of the fraction in the constraint after solution + """ + if abs(value(self.denominator)) >= const.EPS: + return value(self.numerator) / value(self.denominator) + else: + if abs(value(self.numerator)) <= const.EPS: + # zero divided by zero will return 1 + return 1.0 + else: + raise ZeroDivisionError + + def makeElasticSubProblem(self, *args, **kwargs): + """ + Builds an elastic subproblem by adding variables and splitting the + hard constraint + + uses FractionElasticSubProblem + """ + return FractionElasticSubProblem(self, *args, **kwargs) + + +class LpConstraintVar(LpElement): + """A Constraint that can be treated as a variable when constructing + a LpProblem by columns + """ + + def __init__(self, name=None, sense=None, rhs=None, e=None): + LpElement.__init__(self, name) + self.constraint = LpConstraint(name=self.name, sense=sense, rhs=rhs, e=e) + + def addVariable(self, var, coeff): + """ + Adds a variable to the constraint with the + activity coeff + """ + self.constraint.addterm(var, coeff) + + def value(self): + return self.constraint.value() + + +class LpProblem: + """An LP Problem""" + + def __init__(self, name="NoName", sense=const.LpMinimize): + """ + Creates an LP Problem + + This function creates a new LP Problem with the specified associated parameters + + :param name: name of the problem used in the output .lp file + :param sense: of the LP problem objective. \ + Either :data:`~pulp.const.LpMinimize` (default) \ + or :data:`~pulp.const.LpMaximize`. + :return: An LP Problem + """ + if " " in name: + warnings.warn("Spaces are not permitted in the name. Converted to '_'") + name = name.replace(" ", "_") + self.objective = None + self.constraints = _DICT_TYPE() + self.name = name + self.sense = sense + self.sos1 = {} + self.sos2 = {} + self.status = const.LpStatusNotSolved + self.sol_status = const.LpSolutionNoSolutionFound + self.noOverlap = 1 + self.solver = None + self.modifiedVariables = [] + self.modifiedConstraints = [] + self.resolveOK = False + self._variables = [] + self._variable_ids = {} # old school using dict.keys() for a set + self.dummyVar = None + self.solutionTime = 0 + self.solutionCpuTime = 0 + + # locals + self.lastUnused = 0 + + def __repr__(self): + s = self.name + ":\n" + if self.sense == 1: + s += "MINIMIZE\n" + else: + s += "MAXIMIZE\n" + s += repr(self.objective) + "\n" + + if self.constraints: + s += "SUBJECT TO\n" + for n, c in self.constraints.items(): + s += c.asCplexLpConstraint(n) + "\n" + s += "VARIABLES\n" + for v in self.variables(): + s += v.asCplexLpVariable() + " " + const.LpCategories[v.cat] + "\n" + return s + + def __getstate__(self): + # Remove transient data prior to pickling. + state = self.__dict__.copy() + del state["_variable_ids"] + return state + + def __setstate__(self, state): + # Update transient data prior to unpickling. + self.__dict__.update(state) + self._variable_ids = {} + for v in self._variables: + self._variable_ids[v.hash] = v + + def copy(self): + """Make a copy of self. Expressions are copied by reference""" + lpcopy = LpProblem(name=self.name, sense=self.sense) + lpcopy.objective = self.objective + lpcopy.constraints = self.constraints.copy() + lpcopy.sos1 = self.sos1.copy() + lpcopy.sos2 = self.sos2.copy() + return lpcopy + + def deepcopy(self): + """Make a copy of self. Expressions are copied by value""" + lpcopy = LpProblem(name=self.name, sense=self.sense) + if self.objective is not None: + lpcopy.objective = self.objective.copy() + lpcopy.constraints = {} + for k, v in self.constraints.items(): + lpcopy.constraints[k] = v.copy() + lpcopy.sos1 = self.sos1.copy() + lpcopy.sos2 = self.sos2.copy() + return lpcopy + + def toDict(self): + """ + creates a dictionary from the model with as much data as possible. + It replaces variables by variable names. + So it requires to have unique names for variables. + + :return: dictionary with model data + :rtype: dict + """ + try: + self.checkDuplicateVars() + except const.PulpError: + raise const.PulpError( + "Duplicated names found in variables:\nto export the model, variable names need to be unique" + ) + self.fixObjective() + variables = self.variables() + return dict( + objective=dict( + name=self.objective.name, coefficients=self.objective.toDict() + ), + constraints=[v.toDict() for v in self.constraints.values()], + variables=[v.toDict() for v in variables], + parameters=dict( + name=self.name, + sense=self.sense, + status=self.status, + sol_status=self.sol_status, + ), + sos1=list(self.sos1.values()), + sos2=list(self.sos2.values()), + ) + + to_dict = toDict + + @classmethod + def fromDict(cls, _dict): + """ + Takes a dictionary with all necessary information to build a model. + And returns a dictionary of variables and a problem object + + :param _dict: dictionary with the model stored + :return: a tuple with a dictionary of variables and a :py:class:`LpProblem` + """ + + # we instantiate the problem + params = _dict["parameters"] + pb_params = {"name", "sense"} + args = {k: params[k] for k in pb_params} + pb = cls(**args) + pb.status = params["status"] + pb.sol_status = params["sol_status"] + + # recreate the variables. + var = {v["name"]: LpVariable.fromDict(**v) for v in _dict["variables"]} + + # objective function. + # we change the names for the objects: + obj_e = {var[v["name"]]: v["value"] for v in _dict["objective"]["coefficients"]} + pb += LpAffineExpression(e=obj_e, name=_dict["objective"]["name"]) + + # constraints + # we change the names for the objects: + def edit_const(const): + const = dict(const) + const["coefficients"] = { + var[v["name"]]: v["value"] for v in const["coefficients"] + } + return const + + constraints = [edit_const(v) for v in _dict["constraints"]] + for c in constraints: + pb += LpConstraint.fromDict(c) + + # last, parameters, other options + list_to_dict = lambda v: {k: v for k, v in enumerate(v)} + pb.sos1 = list_to_dict(_dict["sos1"]) + pb.sos2 = list_to_dict(_dict["sos2"]) + + return var, pb + + from_dict = fromDict + + def toJson(self, filename, *args, **kwargs): + """ + Creates a json file from the LpProblem information + + :param str filename: filename to write json + :param args: additional arguments for json function + :param kwargs: additional keyword arguments for json function + :return: None + """ + with open(filename, "w") as f: + json.dump(self.toDict(), f, *args, **kwargs) + + to_json = toJson + + @classmethod + def fromJson(cls, filename): + """ + Creates a new Lp Problem from a json file with information + + :param str filename: json file name + :return: a tuple with a dictionary of variables and an LpProblem + :rtype: (dict, :py:class:`LpProblem`) + """ + with open(filename) as f: + data = json.load(f) + return cls.fromDict(data) + + from_json = fromJson + + @classmethod + def fromMPS(cls, filename, sense=const.LpMinimize, **kwargs): + data = mpslp.readMPS(filename, sense=sense, **kwargs) + return cls.fromDict(data) + + def normalisedNames(self): + constraintsNames = {k: "C%07d" % i for i, k in enumerate(self.constraints)} + _variables = self.variables() + variablesNames = {k.name: "X%07d" % i for i, k in enumerate(_variables)} + return constraintsNames, variablesNames, "OBJ" + + def isMIP(self): + for v in self.variables(): + if v.cat == const.LpInteger: + return 1 + return 0 + + def roundSolution(self, epsInt=1e-5, eps=1e-7): + """ + Rounds the lp variables + + Inputs: + - none + + Side Effects: + - The lp variables are rounded + """ + for v in self.variables(): + v.round(epsInt, eps) + + def unusedConstraintName(self): + self.lastUnused += 1 + while 1: + s = "_C%d" % self.lastUnused + if s not in self.constraints: + break + self.lastUnused += 1 + return s + + def valid(self, eps=0): + for v in self.variables(): + if not v.valid(eps): + return False + for c in self.constraints.values(): + if not c.valid(eps): + return False + else: + return True + + def infeasibilityGap(self, mip=1): + gap = 0 + for v in self.variables(): + gap = max(abs(v.infeasibilityGap(mip)), gap) + for c in self.constraints.values(): + if not c.valid(0): + gap = max(abs(c.value()), gap) + return gap + + def addVariable(self, variable): + """ + Adds a variable to the problem before a constraint is added + + @param variable: the variable to be added + """ + if variable.hash not in self._variable_ids: + self._variables.append(variable) + self._variable_ids[variable.hash] = variable + + def addVariables(self, variables): + """ + Adds variables to the problem before a constraint is added + + @param variables: the variables to be added + """ + for v in variables: + self.addVariable(v) + + def variables(self): + """ + Returns the problem variables + + :return: A list containing the problem variables + :rtype: (list, :py:class:`LpVariable`) + """ + if self.objective: + self.addVariables(list(self.objective.keys())) + for c in self.constraints.values(): + self.addVariables(list(c.keys())) + self._variables.sort(key=lambda v: v.name) + return self._variables + + def variablesDict(self): + variables = {} + if self.objective: + for v in self.objective: + variables[v.name] = v + for c in list(self.constraints.values()): + for v in c: + variables[v.name] = v + return variables + + def add(self, constraint, name=None): + self.addConstraint(constraint, name) + + def addConstraint(self, constraint, name=None): + if not isinstance(constraint, LpConstraint): + raise TypeError("Can only add LpConstraint objects") + if name: + constraint.name = name + try: + if constraint.name: + name = constraint.name + else: + name = self.unusedConstraintName() + except AttributeError: + raise TypeError("Can only add LpConstraint objects") + # removed as this test fails for empty constraints + # if len(constraint) == 0: + # if not constraint.valid(): + # raise ValueError, "Cannot add false constraints" + if name in self.constraints: + if self.noOverlap: + raise const.PulpError("overlapping constraint names: " + name) + else: + print("Warning: overlapping constraint names:", name) + self.constraints[name] = constraint + self.modifiedConstraints.append(constraint) + self.addVariables(list(constraint.keys())) + + def setObjective(self, obj): + """ + Sets the input variable as the objective function. Used in Columnwise Modelling + + :param obj: the objective function of type :class:`LpConstraintVar` + + Side Effects: + - The objective function is set + """ + if isinstance(obj, LpVariable): + # allows the user to add a LpVariable as an objective + obj = obj + 0.0 + try: + obj = obj.constraint + name = obj.name + except AttributeError: + name = None + self.objective = obj + self.objective.name = name + self.resolveOK = False + + def __iadd__(self, other): + if isinstance(other, tuple): + other, name = other + else: + name = None + if other is True: + return self + elif other is False: + raise TypeError("A False object cannot be passed as a constraint") + elif isinstance(other, LpConstraintVar): + self.addConstraint(other.constraint) + elif isinstance(other, LpConstraint): + self.addConstraint(other, name) + elif isinstance(other, LpAffineExpression): + if self.objective is not None: + warnings.warn("Overwriting previously set objective.") + self.objective = other + if name is not None: + # we may keep the LpAffineExpression name + self.objective.name = name + elif isinstance(other, LpVariable) or isinstance(other, (int, float)): + if self.objective is not None: + warnings.warn("Overwriting previously set objective.") + self.objective = LpAffineExpression(other) + self.objective.name = name + else: + raise TypeError( + "Can only add LpConstraintVar, LpConstraint, LpAffineExpression or True objects" + ) + return self + + def extend(self, other, use_objective=True): + """ + extends an LpProblem by adding constraints either from a dictionary + a tuple or another LpProblem object. + + @param use_objective: determines whether the objective is imported from + the other problem + + For dictionaries the constraints will be named with the keys + For tuples an unique name will be generated + For LpProblems the name of the problem will be added to the constraints + name + """ + if isinstance(other, dict): + for name in other: + self.constraints[name] = other[name] + elif isinstance(other, LpProblem): + for v in set(other.variables()).difference(self.variables()): + v.name = other.name + v.name + for name, c in other.constraints.items(): + c.name = other.name + name + self.addConstraint(c) + if use_objective: + self.objective += other.objective + else: + for c in other: + if isinstance(c, tuple): + name = c[0] + c = c[1] + else: + name = None + if not name: + name = c.name + if not name: + name = self.unusedConstraintName() + self.constraints[name] = c + + def coefficients(self, translation=None): + coefs = [] + if translation == None: + for c in self.constraints: + cst = self.constraints[c] + coefs.extend([(v.name, c, cst[v]) for v in cst]) + else: + for c in self.constraints: + ctr = translation[c] + cst = self.constraints[c] + coefs.extend([(translation[v.name], ctr, cst[v]) for v in cst]) + return coefs + + def writeMPS( + self, filename, mpsSense=0, rename=0, mip=1, with_objsense: bool = False + ): + """ + Writes an mps files from the problem information + + :param str filename: name of the file to write + :param int mpsSense: + :param bool rename: if True, normalized names are used for variables and constraints + :param mip: variables and variable renames + :return: + Side Effects: + - The file is created + """ + return mpslp.writeMPS( + self, + filename, + mpsSense=mpsSense, + rename=rename, + mip=mip, + with_objsense=with_objsense, + ) + + def writeLP(self, filename, writeSOS=1, mip=1, max_length=100): + """ + Write the given Lp problem to a .lp file. + + This function writes the specifications (objective function, + constraints, variables) of the defined Lp problem to a file. + + :param str filename: the name of the file to be created. + :return: variables + Side Effects: + - The file is created + """ + return mpslp.writeLP( + self, filename=filename, writeSOS=writeSOS, mip=mip, max_length=max_length + ) + + def checkDuplicateVars(self) -> None: + """ + Checks if there are at least two variables with the same name + :return: 1 + :raises `const.PulpError`: if there ar duplicates + """ + name_counter = Counter(variable.name for variable in self.variables()) + repeated_names = { + (name, count) for name, count in name_counter.items() if count >= 2 + } + if repeated_names: + raise const.PulpError(f"Repeated variable names: {repeated_names}") + + def checkLengthVars(self, max_length: int) -> None: + """ + Checks if variables have names smaller than `max_length` + :param int max_length: max size for variable name + :return: + :raises const.PulpError: if there is at least one variable that has a long name + """ + long_names = [ + variable.name + for variable in self.variables() + if len(variable.name) > max_length + ] + if long_names: + raise const.PulpError( + f"Variable names too long for Lp format: {long_names}" + ) + + def assignVarsVals(self, values): + variables = self.variablesDict() + for name in values: + if name != "__dummy": + variables[name].varValue = values[name] + + def assignVarsDj(self, values): + variables = self.variablesDict() + for name in values: + if name != "__dummy": + variables[name].dj = values[name] + + def assignConsPi(self, values): + for name in values: + try: + self.constraints[name].pi = values[name] + except KeyError: + pass + + def assignConsSlack(self, values, activity=False): + for name in values: + try: + if activity: + # reports the activity not the slack + self.constraints[name].slack = -1 * ( + self.constraints[name].constant + float(values[name]) + ) + else: + self.constraints[name].slack = float(values[name]) + except KeyError: + pass + + def get_dummyVar(self): + if self.dummyVar is None: + self.dummyVar = LpVariable("__dummy", 0, 0) + return self.dummyVar + + def fixObjective(self): + if self.objective is None: + self.objective = 0 + wasNone = 1 + else: + wasNone = 0 + if not isinstance(self.objective, LpAffineExpression): + self.objective = LpAffineExpression(self.objective) + if self.objective.isNumericalConstant(): + dummyVar = self.get_dummyVar() + self.objective += dummyVar + else: + dummyVar = None + return wasNone, dummyVar + + def restoreObjective(self, wasNone, dummyVar): + if wasNone: + self.objective = None + elif not dummyVar is None: + self.objective -= dummyVar + + def solve(self, solver=None, **kwargs): + """ + Solve the given Lp problem. + + This function changes the problem to make it suitable for solving + then calls the solver.actualSolve() method to find the solution + + :param solver: Optional: the specific solver to be used, defaults to the + default solver. + + Side Effects: + - The attributes of the problem object are changed in + :meth:`~pulp.solver.LpSolver.actualSolve()` to reflect the Lp solution + """ + + if not (solver): + solver = self.solver + if not (solver): + solver = LpSolverDefault + wasNone, dummyVar = self.fixObjective() + # time it + self.startClock() + status = solver.actualSolve(self, **kwargs) + self.stopClock() + self.restoreObjective(wasNone, dummyVar) + self.solver = solver + return status + + def startClock(self): + "initializes properties with the current time" + self.solutionCpuTime = -clock() + self.solutionTime = -time() + + def stopClock(self): + "updates time wall time and cpu time" + self.solutionTime += time() + self.solutionCpuTime += clock() + + def sequentialSolve( + self, objectives, absoluteTols=None, relativeTols=None, solver=None, debug=False + ): + """ + Solve the given Lp problem with several objective functions. + + This function sequentially changes the objective of the problem + and then adds the objective function as a constraint + + :param objectives: the list of objectives to be used to solve the problem + :param absoluteTols: the list of absolute tolerances to be applied to + the constraints should be +ve for a minimise objective + :param relativeTols: the list of relative tolerances applied to the constraints + :param solver: the specific solver to be used, defaults to the default solver. + + """ + # TODO Add a penalty variable to make problems elastic + # TODO add the ability to accept different status values i.e. infeasible etc + + if not (solver): + solver = self.solver + if not (solver): + solver = LpSolverDefault + if not (absoluteTols): + absoluteTols = [0] * len(objectives) + if not (relativeTols): + relativeTols = [1] * len(objectives) + # time it + self.startClock() + statuses = [] + for i, (obj, absol, rel) in enumerate( + zip(objectives, absoluteTols, relativeTols) + ): + self.setObjective(obj) + status = solver.actualSolve(self) + statuses.append(status) + if debug: + self.writeLP(f"{i}Sequence.lp") + if self.sense == const.LpMinimize: + self += obj <= value(obj) * rel + absol, f"Sequence_Objective_{i}" + elif self.sense == const.LpMaximize: + self += obj >= value(obj) * rel + absol, f"Sequence_Objective_{i}" + self.stopClock() + self.solver = solver + return statuses + + def resolve(self, solver=None, **kwargs): + """ + resolves an Problem using the same solver as previously + """ + if not (solver): + solver = self.solver + if self.resolveOK: + return self.solver.actualResolve(self, **kwargs) + else: + return self.solve(solver=solver, **kwargs) + + def setSolver(self, solver=LpSolverDefault): + """Sets the Solver for this problem useful if you are using + resolve + """ + self.solver = solver + + def numVariables(self): + """ + + :return: number of variables in model + """ + return len(self._variable_ids) + + def numConstraints(self): + """ + + :return: number of constraints in model + """ + return len(self.constraints) + + def getSense(self): + return self.sense + + def assignStatus(self, status, sol_status=None): + """ + Sets the status of the model after solving. + :param status: code for the status of the model + :param sol_status: code for the status of the solution + :return: + """ + if status not in const.LpStatus: + raise const.PulpError("Invalid status code: " + str(status)) + + if sol_status is not None and sol_status not in const.LpSolution: + raise const.PulpError("Invalid solution status code: " + str(sol_status)) + + self.status = status + if sol_status is None: + sol_status = const.LpStatusToSolution.get( + status, const.LpSolutionNoSolutionFound + ) + self.sol_status = sol_status + return True + + +class FixedElasticSubProblem(LpProblem): + """ + Contains the subproblem generated by converting a fixed constraint + :math:`\\sum_{i}a_i x_i = b` into an elastic constraint. + + :param constraint: The LpConstraint that the elastic constraint is based on + :param penalty: penalty applied for violation (+ve or -ve) of the constraints + :param proportionFreeBound: + the proportional bound (+ve and -ve) on + constraint violation that is free from penalty + :param proportionFreeBoundList: the proportional bound on \ + constraint violation that is free from penalty, expressed as a list\ + where [-ve, +ve] + """ + + def __init__( + self, + constraint, + penalty=None, + proportionFreeBound=None, + proportionFreeBoundList=None, + ): + subProblemName = f"{constraint.name}_elastic_SubProblem" + LpProblem.__init__(self, subProblemName, const.LpMinimize) + self.objective = LpAffineExpression() + self.constraint = constraint + self.constant = constraint.constant + self.RHS = -constraint.constant + self.objective = LpAffineExpression() + self += constraint, "_Constraint" + # create and add these variables but disabled + self.freeVar = LpVariable("_free_bound", upBound=0, lowBound=0) + self.upVar = LpVariable("_pos_penalty_var", upBound=0, lowBound=0) + self.lowVar = LpVariable("_neg_penalty_var", upBound=0, lowBound=0) + constraint.addInPlace(self.freeVar + self.lowVar + self.upVar) + if proportionFreeBound: + proportionFreeBoundList = [proportionFreeBound, proportionFreeBound] + if proportionFreeBoundList: + # add a costless variable + self.freeVar.upBound = abs(constraint.constant * proportionFreeBoundList[0]) + self.freeVar.lowBound = -abs( + constraint.constant * proportionFreeBoundList[1] + ) + # Note the reversal of the upbound and lowbound due to the nature of the + # variable + if penalty is not None: + # activate these variables + self.upVar.upBound = None + self.lowVar.lowBound = None + self.objective = penalty * self.upVar - penalty * self.lowVar + + def _findValue(self, attrib): + """ + safe way to get the value of a variable that may not exist + """ + var = getattr(self, attrib, 0) + if var: + if value(var) is not None: + return value(var) + else: + return 0.0 + else: + return 0.0 + + def isViolated(self): + """ + returns true if the penalty variables are non-zero + """ + upVar = self._findValue("upVar") + lowVar = self._findValue("lowVar") + freeVar = self._findValue("freeVar") + result = abs(upVar + lowVar) >= const.EPS + if result: + log.debug( + "isViolated %s, upVar %s, lowVar %s, freeVar %s result %s" + % (self.name, upVar, lowVar, freeVar, result) + ) + log.debug(f"isViolated value lhs {self.findLHSValue()} constant {self.RHS}") + return result + + def findDifferenceFromRHS(self): + """ + The amount the actual value varies from the RHS (sense: LHS - RHS) + """ + return self.findLHSValue() - self.RHS + + def findLHSValue(self): + """ + for elastic constraints finds the LHS value of the constraint without + the free variable and or penalty variable assumes the constant is on the + rhs + """ + upVar = self._findValue("upVar") + lowVar = self._findValue("lowVar") + freeVar = self._findValue("freeVar") + return self.constraint.value() - self.constant - upVar - lowVar - freeVar + + def deElasticize(self): + """de-elasticize constraint""" + self.upVar.upBound = 0 + self.lowVar.lowBound = 0 + + def reElasticize(self): + """ + Make the Subproblem elastic again after deElasticize + """ + self.upVar.lowBound = 0 + self.upVar.upBound = None + self.lowVar.upBound = 0 + self.lowVar.lowBound = None + + def alterName(self, name): + """ + Alters the name of anonymous parts of the problem + + """ + self.name = f"{name}_elastic_SubProblem" + if hasattr(self, "freeVar"): + self.freeVar.name = self.name + "_free_bound" + if hasattr(self, "upVar"): + self.upVar.name = self.name + "_pos_penalty_var" + if hasattr(self, "lowVar"): + self.lowVar.name = self.name + "_neg_penalty_var" + + +class FractionElasticSubProblem(FixedElasticSubProblem): + """ + Contains the subproblem generated by converting a Fraction constraint + numerator/(numerator+complement) = b + into an elastic constraint + + :param name: The name of the elastic subproblem + :param penalty: penalty applied for violation (+ve or -ve) of the constraints + :param proportionFreeBound: the proportional bound (+ve and -ve) on + constraint violation that is free from penalty + :param proportionFreeBoundList: the proportional bound on + constraint violation that is free from penalty, expressed as a list + where [-ve, +ve] + """ + + def __init__( + self, + name, + numerator, + RHS, + sense, + complement=None, + denominator=None, + penalty=None, + proportionFreeBound=None, + proportionFreeBoundList=None, + ): + subProblemName = f"{name}_elastic_SubProblem" + self.numerator = numerator + if denominator is None and complement is not None: + self.complement = complement + self.denominator = numerator + complement + elif denominator is not None and complement is None: + self.denominator = denominator + self.complement = denominator - numerator + else: + raise const.PulpError( + "only one of denominator and complement must be specified" + ) + self.RHS = RHS + self.lowTarget = self.upTarget = None + LpProblem.__init__(self, subProblemName, const.LpMinimize) + self.freeVar = LpVariable("_free_bound", upBound=0, lowBound=0) + self.upVar = LpVariable("_pos_penalty_var", upBound=0, lowBound=0) + self.lowVar = LpVariable("_neg_penalty_var", upBound=0, lowBound=0) + if proportionFreeBound: + proportionFreeBoundList = [proportionFreeBound, proportionFreeBound] + if proportionFreeBoundList: + upProportionFreeBound, lowProportionFreeBound = proportionFreeBoundList + else: + upProportionFreeBound, lowProportionFreeBound = (0, 0) + # create an objective + self += LpAffineExpression() + # There are three cases if the constraint.sense is ==, <=, >= + if sense in [const.LpConstraintEQ, const.LpConstraintLE]: + # create a constraint the sets the upper bound of target + self.upTarget = RHS + upProportionFreeBound + self.upConstraint = LpFractionConstraint( + self.numerator, + self.complement, + const.LpConstraintLE, + self.upTarget, + denominator=self.denominator, + ) + if penalty is not None: + self.lowVar.lowBound = None + self.objective += -1 * penalty * self.lowVar + self.upConstraint += self.lowVar + self += self.upConstraint, "_upper_constraint" + if sense in [const.LpConstraintEQ, const.LpConstraintGE]: + # create a constraint the sets the lower bound of target + self.lowTarget = RHS - lowProportionFreeBound + self.lowConstraint = LpFractionConstraint( + self.numerator, + self.complement, + const.LpConstraintGE, + self.lowTarget, + denominator=self.denominator, + ) + if penalty is not None: + self.upVar.upBound = None + self.objective += penalty * self.upVar + self.lowConstraint += self.upVar + self += self.lowConstraint, "_lower_constraint" + + def findLHSValue(self): + """ + for elastic constraints finds the LHS value of the constraint without + the free variable and or penalty variable assumes the constant is on the + rhs + """ + # uses code from LpFractionConstraint + if abs(value(self.denominator)) >= const.EPS: + return value(self.numerator) / value(self.denominator) + else: + if abs(value(self.numerator)) <= const.EPS: + # zero divided by zero will return 1 + return 1.0 + else: + raise ZeroDivisionError + + def isViolated(self): + """ + returns true if the penalty variables are non-zero + """ + if abs(value(self.denominator)) >= const.EPS: + if self.lowTarget is not None: + if self.lowTarget > self.findLHSValue(): + return True + if self.upTarget is not None: + if self.findLHSValue() > self.upTarget: + return True + else: + # if the denominator is zero the constraint is satisfied + return False + + +def lpSum(vector): + """ + Calculate the sum of a list of linear expressions + + :param vector: A list of linear expressions + """ + return LpAffineExpression().addInPlace(vector) + + +def lpDot(v1, v2): + """Calculate the dot product of two lists of linear expressions""" + if not const.isiterable(v1) and not const.isiterable(v2): + return v1 * v2 + elif not const.isiterable(v1): + return lpDot([v1] * len(v2), v2) + elif not const.isiterable(v2): + return lpDot(v1, [v2] * len(v1)) + else: + return lpSum([lpDot(e1, e2) for e1, e2 in zip(v1, v2)]) diff --git a/utils/pulp/sparse.py b/utils/pulp/sparse.py new file mode 100644 index 0000000..93e0ae8 --- /dev/null +++ b/utils/pulp/sparse.py @@ -0,0 +1,88 @@ +# Sparse : Python basic dictionary sparse matrix + +# Copyright (c) 2007, Stuart Mitchell (s.mitchell@auckland.ac.nz) +# $Id: sparse.py 1704 2007-12-20 21:56:14Z smit023 $ + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +sparse this module provides basic pure python sparse matrix implementation +notably this allows the sparse matrix to be output in various formats +""" + + +class Matrix(dict): + """This is a dictionary based sparse matrix class""" + + def __init__(self, rows, cols): + """initialises the class by creating a matrix that will have the given + rows and columns + """ + self.rows = rows + self.cols = cols + self.rowdict = {row: {} for row in rows} + self.coldict = {col: {} for col in cols} + + def add(self, row, col, item, colcheck=False, rowcheck=False): + if not (rowcheck and row not in self.rows): + if not (colcheck and col not in self.cols): + dict.__setitem__(self, (row, col), item) + self.rowdict[row][col] = item + self.coldict[col][row] = item + else: + print(self.cols) + raise RuntimeError(f"col {col} is not in the matrix columns") + else: + raise RuntimeError(f"row {row} is not in the matrix rows") + + def addcol(self, col, rowitems): + """adds a column""" + if col in self.cols: + for row, item in rowitems.items(): + self.add(row, col, item, colcheck=False) + else: + raise RuntimeError("col is not in the matrix columns") + + def get(self, k, d=0): + return dict.get(self, k, d) + + def col_based_arrays(self): + numEls = len(self) + elemBase = [] + startsBase = [] + indBase = [] + lenBase = [] + for i, col in enumerate(self.cols): + startsBase.append(len(elemBase)) + elemBase.extend(list(self.coldict[col].values())) + indBase.extend(list(self.coldict[col].keys())) + lenBase.append(len(elemBase) - startsBase[-1]) + startsBase.append(len(elemBase)) + return numEls, startsBase, lenBase, indBase, elemBase + + +if __name__ == "__main__": + """unit test""" + rows = list(range(10)) + cols = list(range(50, 60)) + mat = Matrix(rows, cols) + mat.add(1, 52, "item") + mat.add(2, 54, "stuff") + print(mat.col_based_arrays()) diff --git a/utils/pulp/tests/__init__.py b/utils/pulp/tests/__init__.py new file mode 100644 index 0000000..a1ab54b --- /dev/null +++ b/utils/pulp/tests/__init__.py @@ -0,0 +1 @@ +from .run_tests import pulpTestAll diff --git a/utils/pulp/tests/bin_packing_problem.py b/utils/pulp/tests/bin_packing_problem.py new file mode 100644 index 0000000..0419860 --- /dev/null +++ b/utils/pulp/tests/bin_packing_problem.py @@ -0,0 +1,51 @@ +from pulp import * +import random +from itertools import product + + +def _bin_packing_instance(bins, seed=0): + packed_bins = [[] for _ in range(bins)] + bin_size = bins * 100 + random.seed(seed) + for i in range(len(packed_bins)): + remaining_size = bin_size + while remaining_size >= 1: + item = random.randrange(1, remaining_size + 10) + packed_bins[i].append(item) + remaining_size -= item + packed_bins[i][-1] += remaining_size + all_items_with_bin = [(n, i) for i, l in enumerate(packed_bins) for n in l] + + random.shuffle(all_items_with_bin) + items, packing = zip(*all_items_with_bin) + return items, packing, bin_size + + +def create_bin_packing_problem(bins, seed=0): + items, packing, bin_size = _bin_packing_instance(bins=bins, seed=seed) + + prob = LpProblem("bin_packing", LpMinimize) + + bin_indices = [i for i in range(len(items))] + item_indices = [i for i in range(len(items))] + + using_bin = LpVariable.dicts("y", bin_indices, cat=LpBinary) + items_packed = LpVariable.dicts( + "x", indices=product(item_indices, bin_indices), cat=LpBinary + ) + + prob += lpSum(using_bin), "objective" + + # pack every item + for i in item_indices: + prob += lpSum(items_packed[i, b] for b in bin_indices) == 1, f"pack_item_{i}" + + # no bin overfilled + for b in bin_indices: + expr = ( + lpSum([items[i] * items_packed[i, b] for i in item_indices]) + <= bin_size * using_bin[b] + ) + prob += expr, f"respect_bin_size_{b}" + + return prob diff --git a/utils/pulp/tests/run_tests.py b/utils/pulp/tests/run_tests.py new file mode 100644 index 0000000..c6dd764 --- /dev/null +++ b/utils/pulp/tests/run_tests.py @@ -0,0 +1,33 @@ +import unittest +import pulp +from pulp.tests import test_pulp, test_examples, test_gurobipy_env + + +def pulpTestAll(test_docs=False): + runner = unittest.TextTestRunner() + suite_all = get_test_suite(test_docs) + # we run all tests at the same time + ret = runner.run(suite_all) + if not ret.wasSuccessful(): + raise pulp.PulpError("Tests Failed") + + +def get_test_suite(test_docs=False): + # Tests + loader = unittest.TestLoader() + suite_all = unittest.TestSuite() + # we get suite with all PuLP tests + pulp_solver_tests = loader.loadTestsFromModule(test_pulp) + suite_all.addTests(pulp_solver_tests) + # Add tests for gurobipy env + gurobipy_env = loader.loadTestsFromModule(test_gurobipy_env) + suite_all.addTests(gurobipy_env) + # We add examples and docs tests + if test_docs: + docs_examples = loader.loadTestsFromTestCase(test_examples.Examples_DocsTests) + suite_all.addTests(docs_examples) + return suite_all + + +if __name__ == "__main__": + pulpTestAll(test_docs=False) diff --git a/utils/pulp/tests/test_examples.py b/utils/pulp/tests/test_examples.py new file mode 100644 index 0000000..f3922f9 --- /dev/null +++ b/utils/pulp/tests/test_examples.py @@ -0,0 +1,36 @@ +import os +import unittest +import pulp +import shutil + + +class Examples_DocsTests(unittest.TestCase): + def test_examples(self, examples_dir="../../examples"): + import importlib + + this_file = os.path.realpath(__file__) + parent_dir = os.path.dirname(this_file) + files = os.listdir(os.path.join(parent_dir, examples_dir)) + TMP_dir = "_tmp/" + if not os.path.exists(TMP_dir): + os.mkdir(TMP_dir) + for f_name in files: + if os.path.isdir(f_name): + continue + _f_name = "examples." + os.path.splitext(f_name)[0] + os.chdir(TMP_dir) + importlib.import_module(_f_name) + os.chdir("../") + shutil.rmtree(TMP_dir) + + def test_doctest(self): + """ + runs all doctests + """ + import doctest + + doctest.testmod(pulp) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/pulp/tests/test_gurobipy_env.py b/utils/pulp/tests/test_gurobipy_env.py new file mode 100644 index 0000000..9246f38 --- /dev/null +++ b/utils/pulp/tests/test_gurobipy_env.py @@ -0,0 +1,122 @@ +import unittest + +from pulp import GUROBI, LpProblem, LpVariable, const + +try: + import gurobipy as gp + from gurobipy import GRB +except ImportError: + gp = None + + +def check_dummy_env(): + with gp.Env(params={"OutputFlag": 0}): + pass + + +def generate_lp() -> LpProblem: + prob = LpProblem("test", const.LpMaximize) + x = LpVariable("x", 0, 1) + y = LpVariable("y", 0, 1) + z = LpVariable("z", 0, 1) + prob += x + y + z, "obj" + prob += x + y + z <= 1, "c1" + return prob + + +class GurobiEnvTests(unittest.TestCase): + def setUp(self): + if gp is None: + self.skipTest("Skipping all tests in test_gurobipy_env.py") + self.options = {"Method": 0} + self.env_options = {"MemLimit": 1, "OutputFlag": 0} + + def test_gp_env(self): + # Using gp.Env within a context manager + with gp.Env(params=self.env_options) as env: + prob = generate_lp() + solver = GUROBI(msg=False, env=env, **self.options) + prob.solve(solver) + solver.close() + check_dummy_env() + + @unittest.SkipTest + def test_gp_env_no_close(self): + # Not closing results in an error for a single use license. + with gp.Env(params=self.env_options) as env: + prob = generate_lp() + solver = GUROBI(msg=False, env=env, **self.options) + prob.solve(solver) + self.assertRaises(gp.GurobiError, check_dummy_env) + + def test_multiple_gp_env(self): + # Using the same env multiple times + with gp.Env(params=self.env_options) as env: + solver = GUROBI(msg=False, env=env) + prob = generate_lp() + prob.solve(solver) + solver.close() + + solver2 = GUROBI(msg=False, env=env) + prob2 = generate_lp() + prob2.solve(solver2) + solver2.close() + + check_dummy_env() + + @unittest.SkipTest + def test_backward_compatibility(self): + """ + Backward compatibility check as previously the environment was not being + freed. On a single-use license this passes (fails to initialise a dummy + env). + """ + solver = GUROBI(msg=False, **self.options) + prob = generate_lp() + prob.solve(solver) + + self.assertRaises(gp.GurobiError, check_dummy_env) + gp.disposeDefaultEnv() + solver.close() + + def test_manage_env(self): + solver = GUROBI(msg=False, manageEnv=True, **self.options) + prob = generate_lp() + prob.solve(solver) + + solver.close() + check_dummy_env() + + def test_multiple_solves(self): + solver = GUROBI(msg=False, manageEnv=True, **self.options) + prob = generate_lp() + prob.solve(solver) + + solver.close() + check_dummy_env() + + solver2 = GUROBI(msg=False, manageEnv=True, **self.options) + prob.solve(solver2) + + solver2.close() + check_dummy_env() + + @unittest.SkipTest + def test_leak(self): + """ + Check that we cannot initialise environments after a memory leak. On a + single-use license this passes (fails to initialise a dummy env with a + memory leak). + """ + solver = GUROBI(msg=False, **self.options) + prob = generate_lp() + prob.solve(solver) + + tmp = solver.model + solver.close() + + solver2 = GUROBI(msg=False, **self.options) + + prob2 = generate_lp() + prob2.solve(solver2) + self.assertRaises(gp.GurobiError, check_dummy_env) diff --git a/utils/pulp/tests/test_pulp.py b/utils/pulp/tests/test_pulp.py new file mode 100644 index 0000000..9a93e82 --- /dev/null +++ b/utils/pulp/tests/test_pulp.py @@ -0,0 +1,1673 @@ +""" +Tests for pulp +""" +import os +import tempfile + +from pulp.constants import PulpError +from pulp.apis import * +from pulp import LpVariable, LpProblem, lpSum, LpConstraintVar, LpFractionConstraint +from pulp import constants as const +from pulp.tests.bin_packing_problem import create_bin_packing_problem +from pulp.utilities import makeDict +import functools +import unittest + +try: + import gurobipy as gp +except ImportError: + gp = None + +# from: http://lpsolve.sourceforge.net/5.5/mps-format.htm +EXAMPLE_MPS_RHS56 = """NAME TESTPROB +ROWS + N COST + L LIM1 + G LIM2 + E MYEQN +COLUMNS + XONE COST 1 LIM1 1 + XONE LIM2 1 + YTWO COST 4 LIM1 1 + YTWO MYEQN -1 + ZTHREE COST 9 LIM2 1 + ZTHREE MYEQN 1 +RHS + RHS1 LIM1 5 LIM2 10 + RHS1 MYEQN 7 +BOUNDS + UP BND1 XONE 4 + LO BND1 YTWO -1 + UP BND1 YTWO 1 +ENDATA +""" + + +def gurobi_test(test_item): + @functools.wraps(test_item) + def skip_wrapper(*args, **kwargs): + if gp is None: + raise unittest.SkipTest("No gurobipy, can't check license") + try: + test_item(*args, **kwargs) + except gp.GurobiError as ge: + # Skip the test if the failure was due to licensing + if ge.errno == gp.GRB.Error.SIZE_LIMIT_EXCEEDED: + raise unittest.SkipTest("Size-limited Gurobi license") + if ge.errno == gp.GRB.Error.NO_LICENSE: + raise unittest.SkipTest("No Gurobi license") + # Otherwise, let the error go through as-is + raise + + return skip_wrapper + + +def dumpTestProblem(prob): + try: + prob.writeLP("debug.lp") + prob.writeMPS("debug.mps") + except: + print("(Failed to write the test problem.)") + + +class BaseSolverTest: + class PuLPTest(unittest.TestCase): + solveInst = None + + def setUp(self): + self.solver = self.solveInst(msg=False) + if not self.solver.available(): + self.skipTest(f"solver {self.solveInst} not available") + + def tearDown(self): + for ext in ["mst", "log", "lp", "mps", "sol"]: + filename = f"{self._testMethodName}.{ext}" + try: + os.remove(filename) + except: + pass + pass + + def test_pulp_001(self): + """ + Test that a variable is deleted when it is suptracted to 0 + """ + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + c1 = x + y <= 5 + c2 = c1 + z - z + print("\t Testing zero subtraction") + assert str(c2) # will raise an exception + + def test_pulp_009(self): + # infeasible + prob = LpProblem("test09", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += ( + lpSum([v for v in [x] if False]) >= 5, + "c1", + ) # this is a 0 >=5 constraint + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing inconsistent lp solution") + # this was a problem with use_mps=false + if self.solver.__class__ in [PULP_CBC_CMD, COIN_CMD]: + pulpTestCheck( + prob, + self.solver, + [const.LpStatusInfeasible], + {x: 4, y: -1, z: 6, w: 0}, + use_mps=False, + ) + elif self.solver.__class__ in [CHOCO_CMD, MIPCL_CMD]: + # this error is not detected with mps and choco, MIPCL_CMD can only use mps files + pass + else: + pulpTestCheck( + prob, + self.solver, + [ + const.LpStatusInfeasible, + const.LpStatusNotSolved, + const.LpStatusUndefined, + ], + ) + + def test_pulp_010(self): + # Continuous + prob = LpProblem("test010", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing continuous LP solution") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_pulp_011(self): + # Continuous Maximisation + prob = LpProblem("test011", const.LpMaximize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing maximize continuous LP solution") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: 1, z: 8, w: 0} + ) + + def test_pulp_012(self): + # Unbounded + prob = LpProblem("test012", const.LpMaximize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z + w, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing unbounded continuous LP solution") + if self.solver.__class__ in [GUROBI, CPLEX_CMD, YAPOSIB, MOSEK, COPT]: + # These solvers report infeasible or unbounded + pulpTestCheck( + prob, + self.solver, + [const.LpStatusInfeasible, const.LpStatusUnbounded], + ) + elif self.solver.__class__ in [COINMP_DLL, MIPCL_CMD]: + # COINMP_DLL is just plain wrong + # also MIPCL_CMD + print("\t\t Error in CoinMP and MIPCL_CMD: reports Optimal") + pulpTestCheck(prob, self.solver, [const.LpStatusOptimal]) + elif self.solver.__class__ is GLPK_CMD: + # GLPK_CMD Does not report unbounded problems, correctly + pulpTestCheck(prob, self.solver, [const.LpStatusUndefined]) + elif self.solver.__class__ in [GUROBI_CMD, SCIP_CMD, FSCIP_CMD, SCIP_PY]: + # GUROBI_CMD has a very simple interface + pulpTestCheck(prob, self.solver, [const.LpStatusNotSolved]) + elif self.solver.__class__ in [CHOCO_CMD]: + # choco bounds all variables. Would not return unbounded status + pass + else: + pulpTestCheck(prob, self.solver, [const.LpStatusUnbounded]) + + def test_pulp_013(self): + # Long name + prob = LpProblem("test013", const.LpMinimize) + x = LpVariable("x" * 120, 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing Long Names") + if self.solver.__class__ in [ + CPLEX_CMD, + GLPK_CMD, + GUROBI_CMD, + MIPCL_CMD, + SCIP_CMD, + FSCIP_CMD, + SCIP_PY, + HiGHS, + HiGHS_CMD, + XPRESS, + XPRESS_CMD, + ]: + try: + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + except PulpError: + # these solvers should raise an error' + pass + else: + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + + def test_pulp_014(self): + # repeated name + prob = LpProblem("test014", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("x", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing repeated Names") + if self.solver.__class__ in [ + COIN_CMD, + COINMP_DLL, + PULP_CBC_CMD, + CPLEX_CMD, + CPLEX_PY, + GLPK_CMD, + GUROBI_CMD, + CHOCO_CMD, + MIPCL_CMD, + MOSEK, + SCIP_CMD, + FSCIP_CMD, + SCIP_PY, + HiGHS, + HiGHS_CMD, + XPRESS, + XPRESS_CMD, + XPRESS_PY, + ]: + try: + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + except PulpError: + # these solvers should raise an error + pass + else: + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + + def test_pulp_015(self): + # zero constraint + prob = LpProblem("test015", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + prob += lpSum([0, 0]) <= 0, "c5" + print("\t Testing zero constraint") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_pulp_016(self): + # zero objective + prob = LpProblem("test016", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + prob += lpSum([0, 0]) <= 0, "c5" + print("\t Testing zero objective") + pulpTestCheck(prob, self.solver, [const.LpStatusOptimal]) + + def test_pulp_017(self): + # variable as objective + prob = LpProblem("test017", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob.setObjective(x) + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + prob += lpSum([0, 0]) <= 0, "c5" + print("\t Testing LpVariable (not LpAffineExpression) objective") + pulpTestCheck(prob, self.solver, [const.LpStatusOptimal]) + + def test_pulp_018(self): + # Long name in lp + prob = LpProblem("test018", const.LpMinimize) + x = LpVariable("x" * 90, 0, 4) + y = LpVariable("y" * 90, -1, 1) + z = LpVariable("z" * 90, 0) + w = LpVariable("w" * 90, 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + if self.solver.__class__ in [PULP_CBC_CMD, COIN_CMD]: + print("\t Testing Long lines in LP") + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + use_mps=False, + ) + + def test_pulp_019(self): + # divide + prob = LpProblem("test019", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += (2 * x + 2 * y).__div__(2.0) <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing LpAffineExpression divide") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_pulp_020(self): + # MIP + prob = LpProblem("test020", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + print("\t Testing MIP solution") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 3, y: -0.5, z: 7} + ) + + def test_pulp_021(self): + # MIP with floats in objective + prob = LpProblem("test021", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += 1.1 * x + 4.1 * y + 9.1 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + print("\t Testing MIP solution with floats in objective") + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 3, y: -0.5, z: 7}, + objective=64.95, + ) + + def test_pulp_022(self): + # Initial value + prob = LpProblem("test022", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + x.setInitialValue(3) + y.setInitialValue(-0.5) + z.setInitialValue(7) + if self.solver.name in [ + "GUROBI", + "GUROBI_CMD", + "CPLEX_CMD", + "CPLEX_PY", + "COPT", + ]: + self.solver.optionsDict["warmStart"] = True + print("\t Testing Initial value in MIP solution") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 3, y: -0.5, z: 7} + ) + + def test_pulp_023(self): + # Initial value (fixed) + prob = LpProblem("test023", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + solution = {x: 4, y: -0.5, z: 7} + for v in [x, y, z]: + v.setInitialValue(solution[v]) + v.fixValue() + self.solver.optionsDict["warmStart"] = True + print("\t Testing fixing value in MIP solution") + pulpTestCheck(prob, self.solver, [const.LpStatusOptimal], solution) + + def test_pulp_030(self): + # relaxed MIP + prob = LpProblem("test030", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + self.solver.mip = 0 + print("\t Testing MIP relaxation") + if self.solver.__class__ in [ + GUROBI_CMD, + CHOCO_CMD, + MIPCL_CMD, + SCIP_CMD, + FSCIP_CMD, + SCIP_PY, + ]: + # these solvers do not let the problem be relaxed + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 3.0, y: -0.5, z: 7} + ) + else: + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 3.5, y: -1, z: 6.5} + ) + + def test_pulp_040(self): + # Feasibility only + prob = LpProblem("test040", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + print("\t Testing feasibility problem (no objective)") + pulpTestCheck(prob, self.solver, [const.LpStatusOptimal]) + + def test_pulp_050(self): + # Infeasible + prob = LpProblem("test050", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, 10) + prob += x + y <= 5.2, "c1" + prob += x + z >= 10.3, "c2" + prob += -y + z == 17.5, "c3" + print("\t Testing an infeasible problem") + if self.solver.__class__ is GLPK_CMD: + # GLPK_CMD return codes are not informative enough + pulpTestCheck(prob, self.solver, [const.LpStatusUndefined]) + elif self.solver.__class__ in [GUROBI_CMD, FSCIP_CMD]: + # GUROBI_CMD Does not solve the problem + pulpTestCheck(prob, self.solver, [const.LpStatusNotSolved]) + else: + pulpTestCheck(prob, self.solver, [const.LpStatusInfeasible]) + + def test_pulp_060(self): + # Integer Infeasible + prob = LpProblem("test060", const.LpMinimize) + x = LpVariable("x", 0, 4, const.LpInteger) + y = LpVariable("y", -1, 1, const.LpInteger) + z = LpVariable("z", 0, 10, const.LpInteger) + prob += x + y <= 5.2, "c1" + prob += x + z >= 10.3, "c2" + prob += -y + z == 7.4, "c3" + print("\t Testing an integer infeasible problem") + if self.solver.__class__ in [GLPK_CMD, COIN_CMD, PULP_CBC_CMD, MOSEK]: + # GLPK_CMD returns InfeasibleOrUnbounded + pulpTestCheck( + prob, + self.solver, + [const.LpStatusInfeasible, const.LpStatusUndefined], + ) + elif self.solver.__class__ in [COINMP_DLL]: + # Currently there is an error in COINMP for problems where + # presolve eliminates too many variables + print("\t\t Error in CoinMP to be fixed, reports Optimal") + pulpTestCheck(prob, self.solver, [const.LpStatusOptimal]) + elif self.solver.__class__ in [GUROBI_CMD, FSCIP_CMD]: + pulpTestCheck(prob, self.solver, [const.LpStatusNotSolved]) + else: + pulpTestCheck(prob, self.solver, [const.LpStatusInfeasible]) + + def test_pulp_061(self): + # Integer Infeasible + prob = LpProblem("sample", const.LpMaximize) + + dummy = LpVariable("dummy") + c1 = LpVariable("c1", 0, 1, const.LpBinary) + c2 = LpVariable("c2", 0, 1, const.LpBinary) + + prob += dummy + prob += c1 + c2 == 2 + prob += c1 <= 0 + print("\t Testing another integer infeasible problem") + if self.solver.__class__ in [GUROBI_CMD, SCIP_CMD, FSCIP_CMD, SCIP_PY]: + pulpTestCheck(prob, self.solver, [const.LpStatusNotSolved]) + elif self.solver.__class__ in [GLPK_CMD]: + # GLPK_CMD returns InfeasibleOrUnbounded + pulpTestCheck( + prob, + self.solver, + [const.LpStatusInfeasible, const.LpStatusUndefined], + ) + else: + pulpTestCheck(prob, self.solver, [const.LpStatusInfeasible]) + + def test_pulp_070(self): + # Column Based modelling of test_pulp_1 + prob = LpProblem("test070", const.LpMinimize) + obj = LpConstraintVar("obj") + # constraints + a = LpConstraintVar("C1", const.LpConstraintLE, 5) + b = LpConstraintVar("C2", const.LpConstraintGE, 10) + c = LpConstraintVar("C3", const.LpConstraintEQ, 7) + + prob.setObjective(obj) + prob += a + prob += b + prob += c + # Variables + x = LpVariable("x", 0, 4, const.LpContinuous, obj + a + b) + y = LpVariable("y", -1, 1, const.LpContinuous, 4 * obj + a - c) + z = LpVariable("z", 0, None, const.LpContinuous, 9 * obj + b + c) + print("\t Testing column based modelling") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6} + ) + + def test_pulp_075(self): + # Column Based modelling of test_pulp_1 with empty constraints + prob = LpProblem("test075", const.LpMinimize) + obj = LpConstraintVar("obj") + # constraints + a = LpConstraintVar("C1", const.LpConstraintLE, 5) + b = LpConstraintVar("C2", const.LpConstraintGE, 10) + c = LpConstraintVar("C3", const.LpConstraintEQ, 7) + + prob.setObjective(obj) + prob += a + prob += b + prob += c + # Variables + x = LpVariable("x", 0, 4, const.LpContinuous, obj + b) + y = LpVariable("y", -1, 1, const.LpContinuous, 4 * obj - c) + z = LpVariable("z", 0, None, const.LpContinuous, 9 * obj + b + c) + if self.solver.__class__ in [CPLEX_CMD, COINMP_DLL, YAPOSIB, PYGLPK]: + print("\t Testing column based modelling with empty constraints") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6} + ) + + def test_pulp_080(self): + """ + Test the reporting of dual variables slacks and reduced costs + """ + prob = LpProblem("test080", const.LpMinimize) + x = LpVariable("x", 0, 5) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + c1 = x + y <= 5 + c2 = x + z >= 10 + c3 = -y + z == 7 + + prob += x + 4 * y + 9 * z, "obj" + prob += c1, "c1" + prob += c2, "c2" + prob += c3, "c3" + + if self.solver.__class__ in [ + CPLEX_CMD, + COINMP_DLL, + PULP_CBC_CMD, + YAPOSIB, + PYGLPK, + ]: + print("\t Testing dual variables and slacks reporting") + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + sol={x: 4, y: -1, z: 6}, + reducedcosts={x: 0, y: 12, z: 0}, + duals={"c1": 0, "c2": 1, "c3": 8}, + slacks={"c1": 2, "c2": 0, "c3": 0}, + ) + + def test_pulp_090(self): + # Column Based modelling of test_pulp_1 with a resolve + prob = LpProblem("test090", const.LpMinimize) + obj = LpConstraintVar("obj") + # constraints + a = LpConstraintVar("C1", const.LpConstraintLE, 5) + b = LpConstraintVar("C2", const.LpConstraintGE, 10) + c = LpConstraintVar("C3", const.LpConstraintEQ, 7) + + prob.setObjective(obj) + prob += a + prob += b + prob += c + + prob.setSolver(self.solver) # Variables + x = LpVariable("x", 0, 4, const.LpContinuous, obj + a + b) + y = LpVariable("y", -1, 1, const.LpContinuous, 4 * obj + a - c) + prob.resolve() + z = LpVariable("z", 0, None, const.LpContinuous, 9 * obj + b + c) + if self.solver.__class__ in [COINMP_DLL]: + print("\t Testing resolve of problem") + prob.resolve() + # difficult to check this is doing what we want as the resolve is + # overridden if it is not implemented + # test_pulp_Check(prob, self.solver, [const.LpStatusOptimal], {x:4, y:-1, z:6}) + + def test_pulp_100(self): + """ + Test the ability to sequentially solve a problem + """ + # set up a cubic feasible region + prob = LpProblem("test100", const.LpMinimize) + x = LpVariable("x", 0, 1) + y = LpVariable("y", 0, 1) + z = LpVariable("z", 0, 1) + + obj1 = x + 0 * y + 0 * z + obj2 = 0 * x - 1 * y + 0 * z + prob += x <= 1, "c1" + + if self.solver.__class__ in [COINMP_DLL, GUROBI]: + print("\t Testing Sequential Solves") + status = prob.sequentialSolve([obj1, obj2], solver=self.solver) + pulpTestCheck( + prob, + self.solver, + [[const.LpStatusOptimal, const.LpStatusOptimal]], + sol={x: 0, y: 1}, + status=status, + ) + + def test_pulp_110(self): + """ + Test the ability to use fractional constraints + """ + prob = LpProblem("test110", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + prob += LpFractionConstraint(x, z, const.LpConstraintEQ, 0.5, name="c5") + print("\t Testing fractional constraints") + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 10 / 3.0, y: -1 / 3.0, z: 20 / 3.0, w: 0}, + ) + + def test_pulp_120(self): + """ + Test the ability to use Elastic constraints + """ + prob = LpProblem("test120", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w") + prob += x + 4 * y + 9 * z + w, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob.extend((w >= -1).makeElasticSubProblem()) + print("\t Testing elastic constraints (no change)") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: -1} + ) + + def test_pulp_121(self): + """ + Test the ability to use Elastic constraints + """ + prob = LpProblem("test121", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w") + prob += x + 4 * y + 9 * z + w, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob.extend((w >= -1).makeElasticSubProblem(proportionFreeBound=0.1)) + print("\t Testing elastic constraints (freebound)") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: -1.1} + ) + + def test_pulp_122(self): + """ + Test the ability to use Elastic constraints (penalty unchanged) + """ + prob = LpProblem("test122", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w") + prob += x + 4 * y + 9 * z + w, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob.extend((w >= -1).makeElasticSubProblem(penalty=1.1)) + print("\t Testing elastic constraints (penalty unchanged)") + pulpTestCheck( + prob, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: -1.0} + ) + + def test_pulp_123(self): + """ + Test the ability to use Elastic constraints (penalty unbounded) + """ + prob = LpProblem("test123", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w") + prob += x + 4 * y + 9 * z + w, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob.extend((w >= -1).makeElasticSubProblem(penalty=0.9)) + print("\t Testing elastic constraints (penalty unbounded)") + if self.solver.__class__ in [ + COINMP_DLL, + GUROBI, + CPLEX_CMD, + YAPOSIB, + MOSEK, + COPT, + ]: + # COINMP_DLL Does not report unbounded problems, correctly + pulpTestCheck( + prob, + self.solver, + [const.LpStatusInfeasible, const.LpStatusUnbounded], + ) + elif self.solver.__class__ is GLPK_CMD: + # GLPK_CMD Does not report unbounded problems, correctly + pulpTestCheck(prob, self.solver, [const.LpStatusUndefined]) + elif self.solver.__class__ in [GUROBI_CMD, SCIP_CMD, FSCIP_CMD, SCIP_PY]: + pulpTestCheck(prob, self.solver, [const.LpStatusNotSolved]) + elif self.solver.__class__ in [CHOCO_CMD]: + # choco bounds all variables. Would not return unbounded status + pass + else: + pulpTestCheck(prob, self.solver, [const.LpStatusUnbounded]) + + def test_msg_arg(self): + """ + Test setting the msg arg to True does not interfere with solve + """ + prob = LpProblem("test_msg_arg", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + data = prob.toDict() + var1, prob1 = LpProblem.fromDict(data) + x, y, z, w = (var1[name] for name in ["x", "y", "z", "w"]) + if self.solver.name in ["HiGHS"]: + # HiGHS has issues with displaying output in Ubuntu + return + self.solver.msg = True + pulpTestCheck( + prob1, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_pulpTestAll(self): + """ + Test the availability of the function pulpTestAll + """ + print("\t Testing the availability of the function pulpTestAll") + from pulp import pulpTestAll + + def test_export_dict_LP(self): + prob = LpProblem("test_export_dict_LP", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + data = prob.toDict() + var1, prob1 = LpProblem.fromDict(data) + x, y, z, w = (var1[name] for name in ["x", "y", "z", "w"]) + print("\t Testing continuous LP solution - export dict") + pulpTestCheck( + prob1, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_export_dict_LP_no_obj(self): + prob = LpProblem("test_export_dict_LP_no_obj", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0, 0) + prob += x + y >= 5, "c1" + prob += x + z == 10, "c2" + prob += -y + z <= 7, "c3" + prob += w >= 0, "c4" + data = prob.toDict() + var1, prob1 = LpProblem.fromDict(data) + x, y, z, w = (var1[name] for name in ["x", "y", "z", "w"]) + print("\t Testing export dict for LP") + pulpTestCheck( + prob1, self.solver, [const.LpStatusOptimal], {x: 4, y: 1, z: 6, w: 0} + ) + + def test_export_json_LP(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + filename = name + ".json" + prob.toJson(filename, indent=4) + var1, prob1 = LpProblem.fromJson(filename) + try: + os.remove(filename) + except: + pass + x, y, z, w = (var1[name] for name in ["x", "y", "z", "w"]) + print("\t Testing continuous LP solution - export JSON") + pulpTestCheck( + prob1, self.solver, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_export_dict_MIP(self): + import copy + + prob = LpProblem("test_export_dict_MIP", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + data = prob.toDict() + data_backup = copy.deepcopy(data) + var1, prob1 = LpProblem.fromDict(data) + x, y, z = (var1[name] for name in ["x", "y", "z"]) + print("\t Testing export dict MIP") + pulpTestCheck( + prob1, self.solver, [const.LpStatusOptimal], {x: 3, y: -0.5, z: 7} + ) + # we also test that we have not modified the dictionary when importing it + self.assertDictEqual(data, data_backup) + + def test_export_dict_max(self): + prob = LpProblem("test_export_dict_max", const.LpMaximize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + data = prob.toDict() + var1, prob1 = LpProblem.fromDict(data) + x, y, z, w = (var1[name] for name in ["x", "y", "z", "w"]) + print("\t Testing maximize continuous LP solution") + pulpTestCheck( + prob1, self.solver, [const.LpStatusOptimal], {x: 4, y: 1, z: 8, w: 0} + ) + + def test_export_solver_dict_LP(self): + prob = LpProblem("test_export_dict_LP", const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + data = self.solver.toDict() + solver1 = getSolverFromDict(data) + print("\t Testing continuous LP solution - export solver dict") + pulpTestCheck( + prob, solver1, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_export_solver_json(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + self.solver.mip = True + logFilename = name + ".log" + if self.solver.name == "CPLEX_CMD": + self.solver.optionsDict = dict( + gapRel=0.1, + gapAbs=1, + maxMemory=1000, + maxNodes=1, + threads=1, + logPath=logFilename, + warmStart=True, + ) + elif self.solver.name in ["GUROBI_CMD", "COIN_CMD", "PULP_CBC_CMD"]: + self.solver.optionsDict = dict( + gapRel=0.1, gapAbs=1, threads=1, logPath=logFilename, warmStart=True + ) + filename = name + ".json" + self.solver.toJson(filename, indent=4) + solver1 = getSolverFromJson(filename) + try: + os.remove(filename) + except: + pass + print("\t Testing continuous LP solution - export solver JSON") + pulpTestCheck( + prob, solver1, [const.LpStatusOptimal], {x: 4, y: -1, z: 6, w: 0} + ) + + def test_timeLimit(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + self.solver.timeLimit = 20 + # CHOCO has issues when given a time limit + print("\t Testing timeLimit argument") + if self.solver.name != "CHOCO_CMD": + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + + def test_assignInvalidStatus(self): + print("\t Testing invalid status") + t = LpProblem("test") + Invalid = -100 + self.assertRaises(const.PulpError, lambda: t.assignStatus(Invalid)) + self.assertRaises(const.PulpError, lambda: t.assignStatus(0, Invalid)) + + def test_logPath(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + logFilename = name + ".log" + self.solver.optionsDict["logPath"] = logFilename + if self.solver.name in [ + "CPLEX_PY", + "CPLEX_CMD", + "GUROBI", + "GUROBI_CMD", + "PULP_CBC_CMD", + "COIN_CMD", + ]: + print("\t Testing logPath argument") + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + if not os.path.exists(logFilename): + raise PulpError(f"Test failed for solver: {self.solver}") + if not os.path.getsize(logFilename): + raise PulpError(f"Test failed for solver: {self.solver}") + + def test_makeDict_behavior(self): + """ + Test if makeDict is returning the expected value. + """ + headers = [["A", "B"], ["C", "D"]] + values = [[1, 2], [3, 4]] + target = {"A": {"C": 1, "D": 2}, "B": {"C": 3, "D": 4}} + dict_with_default = makeDict(headers, values, default=0) + dict_without_default = makeDict(headers, values) + print("\t Testing makeDict general behavior") + self.assertEqual(dict_with_default, target) + self.assertEqual(dict_without_default, target) + + def test_makeDict_default_value(self): + """ + Test if makeDict is returning a default value when specified. + """ + headers = [["A", "B"], ["C", "D"]] + values = [[1, 2], [3, 4]] + dict_with_default = makeDict(headers, values, default=0) + dict_without_default = makeDict(headers, values) + print("\t Testing makeDict default value behavior") + # Check if a default value is passed + self.assertEqual(dict_with_default["X"]["Y"], 0) + # Check if a KeyError is raised + _func = lambda: dict_without_default["X"]["Y"] + self.assertRaises(KeyError, _func) + + def test_importMPS_maximize(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMaximize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + filename = name + ".mps" + prob.writeMPS(filename) + _vars, prob2 = LpProblem.fromMPS(filename, sense=prob.sense) + _dict1 = getSortedDict(prob) + _dict2 = getSortedDict(prob2) + print("\t Testing reading MPS files - maximize") + self.assertDictEqual(_dict1, _dict2) + + def test_importMPS_noname(self): + name = self._testMethodName + prob = LpProblem("", const.LpMaximize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + filename = name + ".mps" + prob.writeMPS(filename) + _vars, prob2 = LpProblem.fromMPS(filename, sense=prob.sense) + _dict1 = getSortedDict(prob) + _dict2 = getSortedDict(prob2) + print("\t Testing reading MPS files - noname") + self.assertDictEqual(_dict1, _dict2) + + def test_importMPS_integer(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0, None, const.LpInteger) + prob += 1.1 * x + 4.1 * y + 9.1 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7.5, "c3" + filename = name + ".mps" + prob.writeMPS(filename) + _vars, prob2 = LpProblem.fromMPS(filename, sense=prob.sense) + _dict1 = getSortedDict(prob) + _dict2 = getSortedDict(prob2) + print("\t Testing reading MPS files - integer variable") + self.assertDictEqual(_dict1, _dict2) + + def test_importMPS_binary(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMaximize) + dummy = LpVariable("dummy") + c1 = LpVariable("c1", 0, 1, const.LpBinary) + c2 = LpVariable("c2", 0, 1, const.LpBinary) + prob += dummy + prob += c1 + c2 == 2 + prob += c1 <= 0 + filename = name + ".mps" + prob.writeMPS(filename) + _vars, prob2 = LpProblem.fromMPS( + filename, sense=prob.sense, dropConsNames=True + ) + _dict1 = getSortedDict(prob, keyCons="constant") + _dict2 = getSortedDict(prob2, keyCons="constant") + print("\t Testing reading MPS files - binary variable, no constraint names") + self.assertDictEqual(_dict1, _dict2) + + def test_importMPS_RHS_fields56(self): + """Import MPS file with RHS definitions in fields 5 & 6.""" + with tempfile.NamedTemporaryFile(delete=False) as h: + h.write(str.encode(EXAMPLE_MPS_RHS56)) + _, problem = LpProblem.fromMPS(h.name) + os.unlink(h.name) + self.assertEqual(problem.constraints["LIM2"].constant, -10) + + # def test_importMPS_2(self): + # name = self._testMethodName + # # filename = name + ".mps" + # filename = "/home/pchtsp/Downloads/test.mps" + # _vars, _prob = LpProblem.fromMPS(filename) + # _prob.solve() + # for k, v in _vars.items(): + # print(k, v.value()) + + def test_unset_objective_value__is_valid(self): + """Given a valid problem that does not converge, + assert that it is still categorised as valid. + """ + name = self._testMethodName + prob = LpProblem(name, const.LpMaximize) + x = LpVariable("x") + prob += 0 * x + prob += x >= 1 + pulpTestCheck(prob, self.solver, [const.LpStatusOptimal]) + self.assertTrue(prob.valid()) + + def test_unbounded_problem__is_not_valid(self): + """Given an unbounded problem, where x will tend to infinity + to maximise the objective, assert that it is categorised + as invalid.""" + name = self._testMethodName + prob = LpProblem(name, const.LpMaximize) + x = LpVariable("x") + prob += 1000 * x + prob += x >= 1 + self.assertFalse(prob.valid()) + + def test_infeasible_problem__is_not_valid(self): + """Given a problem where x cannot converge to any value + given conflicting constraints, assert that it is invalid.""" + name = self._testMethodName + prob = LpProblem(name, const.LpMaximize) + x = LpVariable("x") + prob += 1 * x + prob += x >= 2 # Constraint x to be more than 2 + prob += x <= 1 # Constraint x to be less than 1 + if self.solver.name in ["GUROBI_CMD", "FSCIP_CMD"]: + pulpTestCheck( + prob, + self.solver, + [ + const.LpStatusNotSolved, + const.LpStatusInfeasible, + const.LpStatusUndefined, + ], + ) + else: + pulpTestCheck( + prob, + self.solver, + [const.LpStatusInfeasible, const.LpStatusUndefined], + ) + self.assertFalse(prob.valid()) + + def test_false_constraint(self): + prob = LpProblem(self._testMethodName, const.LpMinimize) + + def add_const(prob): + prob += 0 - 3 == 0 + + self.assertRaises(TypeError, add_const, prob=prob) + + @gurobi_test + def test_measuring_solving_time(self): + print("\t Testing measuring optimization time") + + time_limit = 10 + solver_settings = dict( + PULP_CBC_CMD=30, + COIN_CMD=30, + SCIP_CMD=30, + GUROBI_CMD=50, + CPLEX_CMD=50, + GUROBI=50, + HiGHS=50, + ) + bins = solver_settings.get(self.solver.name) + if bins is None: + # not all solvers have timeLimit support + return + prob = create_bin_packing_problem(bins=bins, seed=99) + self.solver.timeLimit = time_limit + prob.solve(self.solver) + delta = 20 + reported_time = prob.solutionTime + if self.solver.name in ["PULP_CBC_CMD", "COIN_CMD"]: + reported_time = prob.solutionCpuTime + + self.assertAlmostEqual( + reported_time, + time_limit, + delta=delta, + msg=f"optimization time for solver {self.solver.name}", + ) + self.assertTrue(prob.objective.value() is not None) + for v in prob.variables(): + self.assertTrue(v.varValue is not None) + + def test_invalid_var_names(self): + prob = LpProblem(self._testMethodName, const.LpMinimize) + x = LpVariable("a") + w = LpVariable("b") + y = LpVariable("g", -1, 1) + z = LpVariable("End") + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + print("\t Testing invalid var names") + if self.solver.name not in [ + "GUROBI_CMD", # end is a key-word for LP files + ]: + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + + def test_LpVariable_indexs_param(self): + """ + Test that 'indexs' param continues to work + """ + + prob = LpProblem(self._testMethodName, const.LpMinimize) + customers = [1, 2, 3] + agents = ["A", "B", "C"] + + print("\t Testing 'indexs' param continues to work for LpVariable.dicts") + # explicit param creates a dict of type LpVariable + assign_vars = LpVariable.dicts(name="test", indexs=(customers, agents)) + for k, v in assign_vars.items(): + for a, b in v.items(): + self.assertIsInstance(b, LpVariable) + + # param by position creates a dict of type LpVariable + assign_vars = LpVariable.dicts("test", (customers, agents)) + for k, v in assign_vars.items(): + for a, b in v.items(): + self.assertIsInstance(b, LpVariable) + + print("\t Testing 'indexs' param continues to work for LpVariable.matrix") + # explicit param creates list of list of LpVariable + assign_vars_matrix = LpVariable.matrix( + name="test", indices=(customers, agents) + ) + for a in assign_vars_matrix: + for b in a: + self.assertIsInstance(b, LpVariable) + + # param by position creates list of list of LpVariable + assign_vars_matrix = LpVariable.matrix("test", (customers, agents)) + for a in assign_vars_matrix: + for b in a: + self.assertIsInstance(b, LpVariable) + + def test_LpVariable_indices_param(self): + """ + Test that 'indices' argument works + """ + prob = LpProblem(self._testMethodName, const.LpMinimize) + customers = [1, 2, 3] + agents = ["A", "B", "C"] + + print("\t Testing 'indices' argument works in LpVariable.dicts") + # explicit param creates a dict of type LpVariable + assign_vars = LpVariable.dicts(name="test", indices=(customers, agents)) + for k, v in assign_vars.items(): + for a, b in v.items(): + self.assertIsInstance(b, LpVariable) + + print("\t Testing 'indices' param continues to work for LpVariable.matrix") + # explicit param creates list of list of LpVariable + assign_vars_matrix = LpVariable.matrix( + name="test", indices=(customers, agents) + ) + for a in assign_vars_matrix: + for b in a: + self.assertIsInstance(b, LpVariable) + + def test_LpVariable_indexs_deprecation_logic(self): + """ + Test that logic put in place for deprecation handling of indexs works + """ + print( + "\t Test that logic put in place for deprecation handling of indexs works" + ) + prob = LpProblem(self._testMethodName, const.LpMinimize) + customers = [1, 2, 3] + agents = ["A", "B", "C"] + + with self.assertRaises(TypeError): + # both variables + assign_vars_matrix = LpVariable.dicts( + name="test", indices=(customers, agents), indexs=(customers, agents) + ) + + with self.assertRaises(TypeError): + # no variables + assign_vars_matrix = LpVariable.dicts(name="test") + + with self.assertWarns(DeprecationWarning): + assign_vars_matrix = LpVariable.dicts( + name="test", indexs=(customers, agents) + ) + + def test_parse_cplex_mipopt_solution(self): + """ + Ensures `readsol` can parse CPLEX mipopt solutions (see issue #508). + """ + from io import StringIO + + print("\t Testing that `readsol` can parse CPLEX mipopt solution") + # Example solution generated by CPLEX mipopt solver + file_content = """ + +
+ + + + + + + + + + + + + + """ + solution_file = StringIO(file_content) + + # This call to `readsol` would crash for this solution format #508 + _, _, reducedCosts, shadowPrices, _, _ = CPLEX_CMD().readsol(solution_file) + + # Because mipopt solutions have no `reducedCost` fields + # it should be all None + self.assertTrue(all(c is None for c in reducedCosts.values())) + + # Because mipopt solutions have no `shadowPrices` fields + # it should be all None + self.assertTrue(all(c is None for c in shadowPrices.values())) + + def test_options_parsing_SCIP_HIGHS(self): + name = self._testMethodName + prob = LpProblem(name, const.LpMinimize) + x = LpVariable("x", 0, 4) + y = LpVariable("y", -1, 1) + z = LpVariable("z", 0) + w = LpVariable("w", 0) + prob += x + 4 * y + 9 * z, "obj" + prob += x + y <= 5, "c1" + prob += x + z >= 10, "c2" + prob += -y + z == 7, "c3" + prob += w >= 0, "c4" + # CHOCO has issues when given a time limit + print("\t Testing options parsing") + if self.solver.__class__ in [SCIP_CMD, FSCIP_CMD]: + self.solver.options = ["limits/time", 20] + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + elif self.solver.__class__ in [HiGHS_CMD]: + self.solver.options = ["time_limit", 20] + pulpTestCheck( + prob, + self.solver, + [const.LpStatusOptimal], + {x: 4, y: -1, z: 6, w: 0}, + ) + + +class PULP_CBC_CMDTest(BaseSolverTest.PuLPTest): + solveInst = PULP_CBC_CMD + + +class CPLEX_CMDTest(BaseSolverTest.PuLPTest): + solveInst = CPLEX_CMD + + +class CPLEX_PYTest(BaseSolverTest.PuLPTest): + solveInst = CPLEX_CMD + + +class XPRESS_CMDTest(BaseSolverTest.PuLPTest): + solveInst = XPRESS_CMD + + +class XPRESS_PyTest(BaseSolverTest.PuLPTest): + solveInst = XPRESS_PY + + +class COIN_CMDTest(BaseSolverTest.PuLPTest): + solveInst = COIN_CMD + + +class COINMP_DLLTest(BaseSolverTest.PuLPTest): + solveInst = COINMP_DLL + + +class GLPK_CMDTest(BaseSolverTest.PuLPTest): + solveInst = GLPK_CMD + + +class GUROBITest(BaseSolverTest.PuLPTest): + solveInst = GUROBI + + +class GUROBI_CMDTest(BaseSolverTest.PuLPTest): + solveInst = GUROBI_CMD + + +class PYGLPKTest(BaseSolverTest.PuLPTest): + solveInst = PYGLPK + + +class YAPOSIBTest(BaseSolverTest.PuLPTest): + solveInst = YAPOSIB + + +class CHOCO_CMDTest(BaseSolverTest.PuLPTest): + solveInst = CHOCO_CMD + + +class MIPCL_CMDTest(BaseSolverTest.PuLPTest): + solveInst = MIPCL_CMD + + +class MOSEKTest(BaseSolverTest.PuLPTest): + solveInst = MOSEK + + +class SCIP_CMDTest(BaseSolverTest.PuLPTest): + solveInst = SCIP_CMD + + +class FSCIP_CMDTest(BaseSolverTest.PuLPTest): + solveInst = FSCIP_CMD + + +class SCIP_PYTest(BaseSolverTest.PuLPTest): + solveInst = SCIP_PY + + +class HiGHS_PYTest(BaseSolverTest.PuLPTest): + solveInst = HiGHS + + +class HiGHS_CMDTest(BaseSolverTest.PuLPTest): + solveInst = HiGHS_CMD + + +class COPTTest(BaseSolverTest.PuLPTest): + solveInst = COPT + + +def pulpTestCheck( + prob, + solver, + okstatus, + sol=None, + reducedcosts=None, + duals=None, + slacks=None, + eps=10**-3, + status=None, + objective=None, + **kwargs, +): + if status is None: + status = prob.solve(solver, **kwargs) + if status not in okstatus: + dumpTestProblem(prob) + raise PulpError( + "Tests failed for solver {}:\nstatus == {} not in {}\nstatus == {} not in {}".format( + solver, + status, + okstatus, + const.LpStatus[status], + [const.LpStatus[s] for s in okstatus], + ) + ) + if sol is not None: + for v, x in sol.items(): + if abs(v.varValue - x) > eps: + dumpTestProblem(prob) + raise PulpError( + "Tests failed for solver {}:\nvar {} == {} != {}".format( + solver, v, v.varValue, x + ) + ) + if reducedcosts: + for v, dj in reducedcosts.items(): + if abs(v.dj - dj) > eps: + dumpTestProblem(prob) + raise PulpError( + "Tests failed for solver {}:\nTest failed: var.dj {} == {} != {}".format( + solver, v, v.dj, dj + ) + ) + if duals: + for cname, p in duals.items(): + c = prob.constraints[cname] + if abs(c.pi - p) > eps: + dumpTestProblem(prob) + raise PulpError( + "Tests failed for solver {}:\nconstraint.pi {} == {} != {}".format( + solver, cname, c.pi, p + ) + ) + if slacks: + for cname, slack in slacks.items(): + c = prob.constraints[cname] + if abs(c.slack - slack) > eps: + dumpTestProblem(prob) + raise PulpError( + "Tests failed for solver {}:\nconstraint.slack {} == {} != {}".format( + solver, cname, c.slack, slack + ) + ) + if objective is not None: + z = prob.objective.value() + if abs(z - objective) > eps: + dumpTestProblem(prob) + raise PulpError( + f"Tests failed for solver {solver}:\nobjective {z} != {objective}" + ) + + +def getSortedDict(prob, keyCons="name", keyVars="name"): + _dict = prob.toDict() + _dict["constraints"].sort(key=lambda v: v[keyCons]) + _dict["variables"].sort(key=lambda v: v[keyVars]) + return _dict + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/pulp/utilities.py b/utils/pulp/utilities.py new file mode 100644 index 0000000..407d324 --- /dev/null +++ b/utils/pulp/utilities.py @@ -0,0 +1,232 @@ +# Utility functions +import itertools +import collections + + +def resource_clock(): + import resource + + return resource.getrusage(resource.RUSAGE_CHILDREN).ru_utime + + +def isNumber(x): + """Returns true if x is an int or a float""" + return isinstance(x, (int, float)) + + +def value(x): + """Returns the value of the variable/expression x, or x if it is a number""" + if isNumber(x): + return x + else: + return x.value() + + +def valueOrDefault(x): + """Returns the value of the variable/expression x, or x if it is a number + Variable without value (None) are affected a possible value (within their + bounds).""" + if isNumber(x): + return x + else: + return x.valueOrDefault() + + +def __combination(orgset, k): + """ + fall back if probstat is not installed note it is GPL so cannot + be included + """ + if k == 1: + for i in orgset: + yield (i,) + elif k > 1: + for i, x in enumerate(orgset): + # iterates though to near the end + for s in __combination(orgset[i + 1 :], k - 1): + yield (x,) + s + + +try: # python >= 3.4 + from itertools import combinations as combination +except ImportError: + try: # python 2.7 + from itertools import combination + except ImportError: # pulp's + combination = __combination + + +def __permutation(orgset, k): + """ + fall back if probstat is not installed note it is GPL so cannot + be included + """ + if k == 1: + for i in orgset: + yield (i,) + elif k > 1: + for i, x in enumerate(orgset): + # iterates though to near the end + for s in __permutation(orgset[:i] + orgset[i + 1 :], k - 1): + yield (x,) + s + + +try: # python >= 3.4 + from itertools import permutations as permutation +except ImportError: + try: # python 2.7 + from itertools import permutation + except ImportError: # pulp's + permutation = __permutation + + +def allpermutations(orgset, k): + """ + returns all permutations of orgset with up to k items + + :param orgset: the list to be iterated + :param k: the maxcardinality of the subsets + + :return: an iterator of the subsets + + example: + + >>> c = allpermutations([1,2,3,4],2) + >>> for s in c: + ... print(s) + (1,) + (2,) + (3,) + (4,) + (1, 2) + (1, 3) + (1, 4) + (2, 1) + (2, 3) + (2, 4) + (3, 1) + (3, 2) + (3, 4) + (4, 1) + (4, 2) + (4, 3) + """ + return itertools.chain(*[permutation(orgset, i) for i in range(1, k + 1)]) + + +def allcombinations(orgset, k): + """ + returns all combinations of orgset with up to k items + + :param orgset: the list to be iterated + :param k: the maxcardinality of the subsets + + :return: an iterator of the subsets + + example: + + >>> c = allcombinations([1,2,3,4],2) + >>> for s in c: + ... print(s) + (1,) + (2,) + (3,) + (4,) + (1, 2) + (1, 3) + (1, 4) + (2, 3) + (2, 4) + (3, 4) + """ + return itertools.chain(*[combination(orgset, i) for i in range(1, k + 1)]) + + +def makeDict(headers, array, default=None): + """ + makes a list into a dictionary with the headings given in headings + headers is a list of header lists + array is a list with the data + """ + result, defdict = __makeDict(headers, array, default) + return result + + +def __makeDict(headers, array, default=None): + # this is a recursive function so end the recursion as follows + result = {} + returndefaultvalue = None + if len(headers) == 1: + result.update(dict(zip(headers[0], array))) + defaultvalue = default + else: + for i, h in enumerate(headers[0]): + result[h], defaultvalue = __makeDict(headers[1:], array[i], default) + if default is not None: + f = lambda: defaultvalue + defresult = collections.defaultdict(f) + defresult.update(result) + result = defresult + returndefaultvalue = collections.defaultdict(f) + return result, returndefaultvalue + + +def splitDict(data): + """ + Split a dictionary with lists as the data, into smaller dictionaries + + :param data: A dictionary with lists as the values + + :return: A tuple of dictionaries each containing the data separately, + with the same dictionary keys + """ + # find the maximum number of items in the dictionary + maxitems = max([len(values) for values in data.values()]) + output = [dict() for _ in range(maxitems)] + for key, values in data.items(): + for i, val in enumerate(values): + output[i][key] = val + + return tuple(output) + + +def read_table(data, coerce_type, transpose=False): + """ + Reads in data from a simple table and forces it to be a particular type + + This is a helper function that allows data to be easily constained in a + simple script + ::return: a dictionary of with the keys being a tuple of the strings + in the first row and colum of the table + ::param data: the multiline string containing the table data + ::param coerce_type: the type that the table data is converted to + ::param transpose: reverses the data if needed + + Example: + >>> table_data = ''' + ... L1 L2 L3 L4 L5 L6 + ... C1 6736 42658 70414 45170 184679 111569 + ... C2 217266 227190 249640 203029 153531 117487 + ... C3 35936 28768 126316 2498 130317 74034 + ... C4 73446 52077 108368 75011 49827 62850 + ... C5 174664 177461 151589 153300 59916 135162 + ... C6 186302 189099 147026 164938 149836 286307 + ... ''' + >>> table = read_table(table_data, int) + >>> table[("C1","L1")] + 6736 + >>> table[("C6","L5")] + 149836 + """ + lines = data.splitlines() + headings = lines[1].split() + result = {} + for row in lines[2:]: + items = row.split() + for i, item in enumerate(items[1:]): + if transpose: + key = (headings[i], items[0]) + else: + key = (items[0], headings[i]) + result[key] = coerce_type(item) + return result diff --git a/utils/trans.py b/utils/trans.py new file mode 100644 index 0000000..0e5864e --- /dev/null +++ b/utils/trans.py @@ -0,0 +1,140 @@ +import bpy + + +def t(input_string): + user_lang = bpy.context.preferences.view.language + trans_result = trans_dict.get(input_string) + if trans_result is None: + return input_string + + if "zh_" in user_lang: + return trans_result[1] + if "ja_" in user_lang: + return trans_result[2] + else: + return trans_result[0] + + +trans_dict = { + "start": ["Start", "开始", "スタート"], + "stop": ["Stop", "停止", "停止する"], + "quit": ["Quit", "退出", "終了"], + "apply_mesh": ["Apply Mesh", "应用网格", "メッシュを適用"], + "enable": ["Enable", "启用", "有効化"], + "disable": ["Disable", "禁用", "無効化"], + "key": ["Key", "键", "キー"], + "enter": ["Enter", "回车", "エンター"], + "current_status": ["Current Status", "当前状态", "現在の状態"], + "drawing": ["Drawing", "正在绘制", "描画中"], + "patching": ["Patching", "选择补丁", "パッチ適用"], + "click_drag_to_draw_line": [ + "Click drag to draw line", + "点击并拖动鼠标绘制边缘", + "クリックしてドラッグし線を描く", + ], + "snap_surface": ["Snap surface", "吸附表面", "サーフェスにスナップ"], + "hold_ctrl": ["Hold Ctrl", "按住Ctrl", "Ctrlを押し続ける"], + "hold_shift": ["hold Shift", "按住Shift", "Shiftを押し続ける"], + "scroll": ["Scroll", "滚轮", "スクロール"], + "change_segment": ["Change Segment", "改变分段数", "セグメントを変更"], + "snap_vert": ["Snap Vertex", "吸附顶点", "頂点にスナップ"], + "snap_exist_path": ["Snap Exist Path", "吸附已有路径", "既存のパスにスナップ"], + "solver_solution": ["Solver Solution", "求解器结果", "ソルバーの解"], + "pattern": ["Pattern", "形状", "パターン"], + "solver_constraint": ["Solver Constraint", "求解器约束", "ソルバーの制約"], + "change_padding": ["Change Padding", "改变填充数量", "パディングを変更"], + "rotate_pattern": ["Rotate Pattern", "旋转形状", "パターンを回転"], + "clear_constraint": ["Clear Constraint", "清除约束", "制約をクリア"], + "switch_pattern": ["Switch Pattern", "切换形状", "パターンを切り替え"], + "no_solution": ["No Solution", "无解", "解なし"], + "not_even_num_edges": ["Not Even Number Edges", "非偶数边", "偶数でないエッジ数"], + "no_match_pattern": [ + "No Match Pattern", + "未找到匹配的形状", + "一致するパターンなし", + ], + "rotation": ["Rotation", "旋转", "回転"], + "paddings": ["Paddings", "填充", "パディング"], + "var_constraint": ["Variable Constraint", "变量约束", "変数制約"], + "current_para": ["Current Parameters", "当前参数", "現在のパラメータ"], + "snap_obj_setting_desc": [ + "snap target obj, if empty snap to whole collection", + "吸附目标物体,如果留空则吸附整个集合", + "ターゲットオブジェクトをスナップします。空の場合はコレクション全体にスナップします", + ], + "flip_normal": ["Flip Normals", "反转法线", "法線を反転する"], + "smooth_mesh": ["Smooth Mesh", "平滑网格", "スムーズ メッシュ"], + "guide_mode": ["Guide Mode", "引导模式", "ガイドモード"], + "only_clear_rotation": ["Only clear rotation", "只清除旋转", "回転のみクリア"], + "only_clear_var": ["Only clear variable", "只清除变量", "変数のみクリア"], + "snap_opposite": [ + "Snap to normals opposite surface", + "吸附法线方向相反的表面", + "サーフェスの反対側の法線にスナップします", + ], + "skip_no_solution_pattern": [ + "Skip no solution pattern", + "跳过无解形状", + "スキップなしの解決パターン", + ], + "padding": ["Padding", "填充", "パディング"], + "snap_selected_to_surface": [ + "Snap Selected Vertices to Surface", + "吸附选中顶点至表面", + "選択頂点スナップ", + ], + "right_click": ["Right Click", "右键", "右クリック"], + "cancel_this_draw": ["Cancel this Darw", "取消绘制", "この図面をキャンセル"], + "left_click_release": ["Left Click Release", "释放左键", "左クリックリリース"], + "apply_this_draw": ["Apply this Draw", "应用绘制", "この図面を適用"], + "delete_highlight_edge": [ + "Delete highlight Edge", + "删除高亮边", + "ハイライトエッジを削除", + ], + "faces_normal_toward_viewport": [ + "Change Faces Normal Toward Viewport", + "修改面法向朝向视图", + "ビューポートに向かって面の法線を変更", + ], + "draw_on_axis_of_symmetry": [ + "Draw on Axis of Symmetry", + "沿对称轴绘制", + "対称軸に沿って描く", + ], + "you_cant_undo_here": [ + "Undo is not allowed here, quit and retry", + "此處無法撤回,退出再試", + "ここでは元に戻すことはできません。終了して再試行してください", + ], + "faces": [ + "Faces", + "面", + "ここでは元に戻すことはできません。終了して再試行してください", + ], + "verts": [ + "verts", + "頂點", + "頂点", + ], + "edges": [ + "edges", + "边", + "エッジ", + ], + "created": [ + "created", + "創建", + "作成された", + ], + "font_size": [ + "Font Size", + "字體大小", + "フォントサイズ", + ], + "hotkey_display_position": [ + "Hotkey Display Position", + "熱鍵的展示位置", + "ホットキーの表示位置", + ], +}