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:
2026-03-03 19:24:57 +08:00
commit ab91b120e6
44 changed files with 17551 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__

5
.vscode/settings.json vendored Normal file
View 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
View 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
View 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及其网格每切分一个boundaryboundary会被删除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
View 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
View 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
View 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
View 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环路列表元素是looploop是元组的列表元组第一个元素是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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
preference.py Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
utils/pulp/__init__.py Normal file
View 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
View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

493
utils/pulp/apis/core.py Normal file
View 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

View 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
View 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

View 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
]

View 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")

View 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

View 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
View 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"
)

View 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
View 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
View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

88
utils/pulp/sparse.py Normal file
View 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())

View File

@@ -0,0 +1 @@
from .run_tests import pulpTestAll

View 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

View 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)

View 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()

View 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)

File diff suppressed because it is too large Load Diff

232
utils/pulp/utilities.py Normal file
View 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
View 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",
"熱鍵的展示位置",
"ホットキーの表示位置",
],
}