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配置文件
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"python-envs.defaultEnvManager": "ms-python.python:conda",
|
||||
"python-envs.defaultPackageManager": "ms-python.python:conda",
|
||||
"python-envs.pythonProjects": []
|
||||
}
|
||||
45
__init__.py
Normal file
45
__init__.py
Normal file
@@ -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
|
||||
535
boundary.py
Normal file
535
boundary.py
Normal file
@@ -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
|
||||
8
const.py
Normal file
8
const.py
Normal file
@@ -0,0 +1,8 @@
|
||||
addon_prefix = "easy_patch"
|
||||
|
||||
draw_path_threshold = 2 # 绘制路径的最小间隔
|
||||
hover_threshold = 20 # 触发鼠标悬浮的最小间隔
|
||||
|
||||
mouse_move_threshold = 2 # px
|
||||
|
||||
snap_vertex_threshold = 30
|
||||
2
debug.log
Normal file
2
debug.log
Normal file
@@ -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
|
||||
785
draw.py
Normal file
785
draw.py
Normal file
@@ -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()
|
||||
118
g.py
Normal file
118
g.py
Normal file
@@ -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
|
||||
194
generate_loop.py
Normal file
194
generate_loop.py
Normal file
@@ -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
|
||||
318
loop.py
Normal file
318
loop.py
Normal file
@@ -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
|
||||
708
op.py
Normal file
708
op.py
Normal file
@@ -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)
|
||||
1662
pattern.py
Normal file
1662
pattern.py
Normal file
File diff suppressed because it is too large
Load Diff
23
preference.py
Normal file
23
preference.py
Normal file
@@ -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,)
|
||||
20
property.py
Normal file
20
property.py
Normal file
@@ -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,)
|
||||
49
ui.py
Normal file
49
ui.py
Normal file
@@ -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,)
|
||||
1145
utils/functions.py
Normal file
1145
utils/functions.py
Normal file
File diff suppressed because it is too large
Load Diff
41
utils/pulp/__init__.py
Normal file
41
utils/pulp/__init__.py
Normal file
@@ -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
|
||||
165
utils/pulp/apis/__init__.py
Normal file
165
utils/pulp/apis/__init__.py
Normal file
@@ -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
|
||||
158
utils/pulp/apis/choco_api.py
Normal file
158
utils/pulp/apis/choco_api.py
Normal file
@@ -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
|
||||
873
utils/pulp/apis/coin_api.py
Normal file
873
utils/pulp/apis/coin_api.py
Normal file
@@ -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
|
||||
1090
utils/pulp/apis/copt_api.py
Normal file
1090
utils/pulp/apis/copt_api.py
Normal file
File diff suppressed because it is too large
Load Diff
493
utils/pulp/apis/core.py
Normal file
493
utils/pulp/apis/core.py
Normal file
@@ -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
|
||||
579
utils/pulp/apis/cplex_api.py
Normal file
579
utils/pulp/apis/cplex_api.py
Normal file
@@ -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
|
||||
409
utils/pulp/apis/glpk_api.py
Normal file
409
utils/pulp/apis/glpk_api.py
Normal file
@@ -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
|
||||
566
utils/pulp/apis/gurobi_api.py
Normal file
566
utils/pulp/apis/gurobi_api.py
Normal file
@@ -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": "<url>",
|
||||
"CSAPIAccessID": "<access-id>",
|
||||
"CSAPISecret": "<api-key>",
|
||||
}
|
||||
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
|
||||
]
|
||||
409
utils/pulp/apis/highs_api.py
Normal file
409
utils/pulp/apis/highs_api.py
Normal file
@@ -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")
|
||||
154
utils/pulp/apis/mipcl_api.py
Normal file
154
utils/pulp/apis/mipcl_api.py
Normal file
@@ -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
|
||||
345
utils/pulp/apis/mosek_api.py
Normal file
345
utils/pulp/apis/mosek_api.py
Normal file
@@ -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
|
||||
679
utils/pulp/apis/scip_api.py
Normal file
679
utils/pulp/apis/scip_api.py
Normal file
@@ -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: <something>'
|
||||
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"
|
||||
)
|
||||
760
utils/pulp/apis/xpress_api.py
Normal file
760
utils/pulp/apis/xpress_api.py
Normal file
@@ -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)
|
||||
104
utils/pulp/constants.py
Normal file
104
utils/pulp/constants.py
Normal file
@@ -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
|
||||
401
utils/pulp/mps_lp.py
Normal file
401
utils/pulp/mps_lp.py
Normal file
@@ -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
|
||||
15
utils/pulp/pulp.cfg.buildout
Normal file
15
utils/pulp/pulp.cfg.buildout
Normal file
@@ -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
|
||||
18
utils/pulp/pulp.cfg.win
Normal file
18
utils/pulp/pulp.cfg.win
Normal file
@@ -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
|
||||
2298
utils/pulp/pulp.py
Normal file
2298
utils/pulp/pulp.py
Normal file
File diff suppressed because it is too large
Load Diff
88
utils/pulp/sparse.py
Normal file
88
utils/pulp/sparse.py
Normal file
@@ -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())
|
||||
1
utils/pulp/tests/__init__.py
Normal file
1
utils/pulp/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .run_tests import pulpTestAll
|
||||
51
utils/pulp/tests/bin_packing_problem.py
Normal file
51
utils/pulp/tests/bin_packing_problem.py
Normal file
@@ -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
|
||||
33
utils/pulp/tests/run_tests.py
Normal file
33
utils/pulp/tests/run_tests.py
Normal file
@@ -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)
|
||||
36
utils/pulp/tests/test_examples.py
Normal file
36
utils/pulp/tests/test_examples.py
Normal file
@@ -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()
|
||||
122
utils/pulp/tests/test_gurobipy_env.py
Normal file
122
utils/pulp/tests/test_gurobipy_env.py
Normal file
@@ -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)
|
||||
1673
utils/pulp/tests/test_pulp.py
Normal file
1673
utils/pulp/tests/test_pulp.py
Normal file
File diff suppressed because it is too large
Load Diff
232
utils/pulp/utilities.py
Normal file
232
utils/pulp/utilities.py
Normal file
@@ -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
|
||||
140
utils/trans.py
Normal file
140
utils/trans.py
Normal file
@@ -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",
|
||||
"熱鍵的展示位置",
|
||||
"ホットキーの表示位置",
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user