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

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