修复设计计算错误并优化分享

This commit is contained in:
Lihatoo 2026-07-26 03:00:54 +08:00
parent dd21623cb5
commit 30cdd05914
5 changed files with 916 additions and 28 deletions

View file

@ -82,8 +82,11 @@ UNIT_SCALE = {
}
IUPAC_CODES = "ACGTUWSMKRYBDHVN"
IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?")
FIXED_BASES = set("ACGTU")
COMPLEMENT_BASE = str.maketrans({"A": "T", "C": "G", "G": "C", "T": "A", "U": "A"})
VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"}
FIXED_TARGET_POLICIES = {"exclude_from_optimization", "include"}
CANCEL_REQUESTED_STATUS = "cancel_requested"
CANCELED_STATUS = "canceled"
TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS}
@ -245,6 +248,54 @@ def is_valid_iupac_constraint(sequence):
return True
def expand_iupac_constraint(sequence):
seq = normalize_design_sequence(sequence)
output = []
index = 0
while index < len(seq):
match = IUPAC_CONSTRAINT_TOKEN.match(seq, index)
if not match:
raise ValueError(f"Invalid sequence constraint near: {seq[index:]}")
token = match.group(0)
base = token[0]
count = int(token[1:] or "1")
output.append(base * count)
index = match.end()
return "".join(output)
def is_mutable_iupac_constraint(sequence):
expanded = expand_iupac_constraint(sequence)
return any(base not in FIXED_BASES for base in expanded)
def reverse_complement_fixed(sequence):
return sequence.upper().translate(COMPLEMENT_BASE)[::-1]
def parse_domain_tokens(text):
tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()]
output = []
for token in tokens:
complement = False
domain_name = token
if token.startswith("~"):
complement = True
domain_name = token[1:]
elif token.endswith("*"):
complement = True
domain_name = token[:-1]
output.append((domain_name, complement))
return output
def get_mapping_value(mapping, key, default=None):
try:
return mapping[key]
except Exception:
return default
def ensure_unit_interval_limits(lower, upper, label):
if not (0 <= lower <= upper <= 1):
raise ValueError(f"{label} limits must satisfy 0 <= lower <= upper <= 1.")
@ -320,6 +371,7 @@ def parse_design_options(payload):
"seed": int(raw.get("seed", 0)),
"wobble_mutations": bool(raw.get("wobble_mutations", False)),
"max_time_seconds": int(raw.get("max_time_seconds", 0)),
"fixed_target_policy": str(raw.get("fixed_target_policy", "exclude_from_optimization")).strip().lower(),
}
if options["trials"] < 1 or options["trials"] > 8:
raise ValueError("design trials must be between 1 and 8.")
@ -331,6 +383,8 @@ def parse_design_options(payload):
raise ValueError("design stop_condition must be between 0 and 1.")
if options["max_time_seconds"] < 0:
raise ValueError("design max_time_seconds must be non-negative.")
if options["fixed_target_policy"] not in FIXED_TARGET_POLICIES:
raise ValueError("design fixed_target_policy must be exclude_from_optimization or include.")
return options
@ -388,11 +442,14 @@ def build_design_domains(domain_payload):
domain = Domain(sequence_constraint, name=name)
domain_map[name] = domain
mutable = is_mutable_iupac_constraint(sequence_constraint)
ordered.append(
{
"name": name,
"constraint": sequence_constraint,
"object": domain,
"mutable": mutable,
"fixed_sequence": None if mutable else expand_iupac_constraint(sequence_constraint),
}
)
return domain_map, ordered
@ -424,11 +481,12 @@ def parse_domain_composition(text, domain_map):
return domains
def build_design_strands(strand_payload, domain_map=None):
def build_design_strands(strand_payload, domain_map=None, domain_rows=None):
if not strand_payload:
raise ValueError("At least one design strand is required.")
domain_map = domain_map or {}
domain_info = {item["name"]: item for item in (domain_rows or [])}
use_domain_composition = bool(domain_map)
target_strand_map = {}
ordered = []
@ -457,11 +515,28 @@ def build_design_strands(strand_payload, domain_map=None):
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "sequence_constraint"
constraint_value = sequence_constraint
mutable = is_mutable_iupac_constraint(sequence_constraint)
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint)
else:
inline_domain = None
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "domain_composition"
constraint_value = raw_definition
fixed_parts = []
mutable = False
for domain_name, complement in parse_domain_tokens(raw_definition):
info = domain_info.get(domain_name)
if info is None:
mutable = True
fixed_parts = []
break
if info.get("mutable"):
mutable = True
fixed_parts = []
break
part = info.get("fixed_sequence") or ""
fixed_parts.append(reverse_complement_fixed(part) if complement else part)
fixed_sequence = None if mutable else "".join(fixed_parts)
else:
if not is_valid_iupac_constraint(sequence_constraint):
raise ValueError(f"Design strand {name} contains unsupported constraint characters.")
@ -470,6 +545,8 @@ def build_design_strands(strand_payload, domain_map=None):
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "sequence_constraint"
constraint_value = sequence_constraint
mutable = is_mutable_iupac_constraint(sequence_constraint)
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint)
target_strand_map[name] = target_strand
ordered.append(
@ -481,6 +558,8 @@ def build_design_strands(strand_payload, domain_map=None):
"object": target_strand,
"domains": strand_domains,
"domain": inline_domain,
"mutable": mutable,
"fixed_sequence": fixed_sequence,
}
)
@ -728,13 +807,14 @@ def build_soft_constraints(payload, domain_map, strand_map, target_complex_map):
return constraints
def parse_design_complexes(payload, target_strand_map):
def parse_design_complexes(payload, target_strand_map, strand_rows=None):
target_rows = payload.get("design_complexes") or payload.get("design_targets") or []
if not target_rows:
raise ValueError("At least one design target complex is required.")
targets = []
target_complex_map = {}
strand_info = {item["name"]: item for item in (strand_rows or [])}
for idx, row in enumerate(target_rows, start=1):
name = (row.get("name") or f"target_{idx}").strip() or f"target_{idx}"
@ -761,11 +841,14 @@ def parse_design_complexes(payload, target_strand_map):
structure,
name=name,
)
mutable = any(strand_info.get(token, {}).get("mutable", True) for token in tokens)
target_payload = {
"name": name,
"strands": tokens,
"structure": structure,
"object": target_complex,
"mutable": mutable,
"optimization_status": "included",
}
targets.append(target_payload)
target_complex_map[name] = target_complex
@ -859,6 +942,21 @@ def parse_design_tubes(payload, target_rows, target_complex_map, default_max_siz
return ordered_rows, tubes
def payload_with_allowed_design_targets(payload, allowed_target_names):
allowed = set(allowed_target_names)
filtered_tubes = []
for row in payload.get("design_tubes") or []:
on_targets = [entry for entry in (row.get("on_targets") or []) if (entry.get("complex") or "").strip() in allowed]
if on_targets:
tube_row = dict(row)
tube_row["on_targets"] = on_targets
filtered_tubes.append(tube_row)
output = dict(payload)
output["design_tubes"] = filtered_tubes
return output
def validate_design_object_names(design_domains, design_strands, target_rows, tube_rows):
name_map = {}
for kind, rows in (
@ -1206,46 +1304,62 @@ def serialize_design_result(
ordered_domains,
ordered_strands,
):
analysis_map = getattr(design_result, "to_analysis", {}) or {}
designed_domains = []
designed_domain_map = getattr(design_result, "domains", {}) or {}
for item in ordered_domains:
designed_domain = designed_domain_map.get(item["object"])
domain_sequence = str(designed_domain) if designed_domain is not None else item.get("fixed_sequence")
designed_domains.append(
{
"name": item["name"],
"constraint": item["constraint"],
"sequence": str(designed_domain) if designed_domain is not None else None,
"length": len(str(designed_domain)) if designed_domain is not None else None,
"sequence": domain_sequence,
"length": len(domain_sequence) if domain_sequence is not None else None,
"mutable": bool(item.get("mutable", True)),
}
)
designed_strands = []
designed_strand_by_name = {}
for item in ordered_strands:
target_strand = item["object"]
analysis_strand = design_result.to_analysis[target_strand]
analysis_strand = get_mapping_value(analysis_map, target_strand)
sequence = str(analysis_strand) if analysis_strand is not None else item.get("fixed_sequence")
designed_strands.append(
{
"name": item["name"],
"constraint": item["constraint"],
"constraint_kind": item.get("constraint_kind", "sequence_constraint"),
"definition": item.get("definition", item["constraint"]),
"sequence": str(analysis_strand),
"length": len(str(analysis_strand)),
"sequence": sequence,
"length": len(sequence) if sequence is not None else None,
"mutable": bool(item.get("mutable", True)),
}
)
if sequence is not None:
designed_strand_by_name[item["name"]] = sequence
target_complexes = []
for target in target_rows:
target_complex = target["object"]
analysis_complex = design_result.to_analysis[target_complex]
analysis_complex = get_mapping_value(analysis_map, target_complex)
if analysis_complex is not None:
display = stringify_complex(analysis_complex)
sequence = flatten_sequence(analysis_complex)
else:
display = " + ".join(target["strands"])
sequence = "".join(designed_strand_by_name.get(name, "") for name in target["strands"])
target_complexes.append(
{
"name": target["name"],
"display": stringify_complex(analysis_complex),
"display": display,
"strand_names": list(target["strands"]),
"structure": target["structure"],
"sequence": flatten_sequence(analysis_complex),
"sequence": sequence,
"target_concentration_M": target.get("target_concentration_M"),
"optimization_status": target.get("optimization_status", "included"),
"mutable": bool(target.get("mutable", True)),
}
)
@ -1291,6 +1405,11 @@ def serialize_design_result(
"seed": design_options["seed"],
"wobble_mutations": design_options["wobble_mutations"],
"max_time_seconds": design_options["max_time_seconds"],
"fixed_target_policy": design_options["fixed_target_policy"],
},
"optimization": {
"included_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "included"],
"excluded_fixed_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "fixed_excluded"],
},
"target_tubes": [
{
@ -1346,22 +1465,42 @@ def run_job_payload(payload):
target_strand_map, design_strands = build_design_strands(
payload.get("strands") or [],
domain_map=design_domain_map,
domain_rows=design_domains,
)
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map)
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map, strand_rows=design_strands)
optimization_target_rows = target_rows
optimization_target_complex_map = target_complex_map
if design_options["fixed_target_policy"] == "exclude_from_optimization":
optimization_target_rows = [row for row in target_rows if row.get("mutable", True)]
fixed_names = {row["name"] for row in target_rows if not row.get("mutable", True)}
for row in target_rows:
if row["name"] in fixed_names:
row["optimization_status"] = "fixed_excluded"
optimization_target_complex_map = {
row["name"]: row["object"] for row in optimization_target_rows
}
if not optimization_target_rows:
raise ValueError("Design contains no mutable target complexes after fixed-target filtering.")
hard_constraints = build_hard_constraints(payload, design_domain_map, target_strand_map)
soft_constraints = build_soft_constraints(
payload,
design_domain_map,
target_strand_map,
target_complex_map,
optimization_target_complex_map,
)
tube_rows = []
design_tubes = []
if mode == "tube":
optimization_payload = payload
if design_options["fixed_target_policy"] == "exclude_from_optimization":
optimization_payload = payload_with_allowed_design_targets(
payload,
{row["name"] for row in optimization_target_rows},
)
tube_rows, design_tubes = parse_design_tubes(
payload,
target_rows,
target_complex_map,
optimization_payload,
optimization_target_rows,
optimization_target_complex_map,
design_options["off_target_max_size"],
)
validate_design_object_names(design_domains, design_strands, target_rows, tube_rows)
@ -1386,7 +1525,7 @@ def run_job_payload(payload):
)
elif mode == "complex":
design_job = complex_design(
complexes=[row["object"] for row in target_rows],
complexes=[row["object"] for row in optimization_target_rows],
model=model,
options=design_job_options,
hard_constraints=hard_constraints,
@ -1923,6 +2062,69 @@ def get_share(share_id):
return dict(item) if item else None
def public_share_payload(share_id, *, record_access=True):
metadata = ACCOUNT_STORE.share_metadata(share_id)
if metadata is None:
return get_share(share_id)
live = get_job_data(metadata["job_id"], include_payload=True)
if live is not None:
if record_access:
ACCOUNT_STORE.record_share_access(share_id)
payload = live.get("payload")
if payload is None:
account_job = ACCOUNT_STORE.get_job(metadata["user_id"], metadata["job_id"], include_content=True)
payload = account_job.get("payload") if account_job else None
result = live.get("result")
error = live.get("error")
return {
"id": share_id,
"share_id": share_id,
"job_id": metadata["job_id"],
"created_at": metadata["created_at"],
"status": live.get("status"),
"updated_at": live.get("updated_at"),
"elapsed_seconds": live.get("elapsed_seconds"),
"payload": payload,
"result": result,
"error": error,
"result_summary": build_history_summary(payload, live),
}
return ACCOUNT_STORE.resolve_share(share_id, record_access=record_access)
def build_history_summary(payload, fallback=None):
payload = payload or {}
fallback = fallback or {}
model = payload.get("model") or {}
workflow = payload.get("workflow") or fallback.get("workflow") or "analysis"
mode = payload.get("mode") or fallback.get("mode") or "tube"
strands = payload.get("strands") or []
if workflow == "design":
complexes = payload.get("design_complexes") or payload.get("design_targets") or []
tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])]
max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0))
else:
complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()]
max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0)
return {
"workflow": workflow,
"mode": mode,
"material": model.get("material", fallback.get("material", "rna")),
"celsius": float(model.get("celsius", fallback.get("celsius", 37)) or 37),
"sodium": float(model.get("sodium", fallback.get("sodium", 0)) or 0),
"magnesium": float(model.get("magnesium", fallback.get("magnesium", 0)) or 0),
"max_size": max_size,
"strand_count": len(strands),
"complex_count": len(complexes),
"compute": list(payload.get("compute") or fallback.get("compute") or ([] if workflow != "design" else ["design"])),
"trials": int((payload.get("design") or {}).get("trials", fallback.get("trials", 0)) or 0),
"stop_condition": float((payload.get("design") or {}).get("stop_condition", fallback.get("stop_condition", 0)) or 0),
"max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", fallback.get("max_time_seconds", 0)) or 0),
}
def recover_interrupted_jobs():
client = redis_client()
recovered = 0
@ -2043,6 +2245,7 @@ EXAMPLE_PAYLOAD = {
"seed": 0,
"wobble_mutations": False,
"max_time_seconds": 0,
"fixed_target_policy": "exclude_from_optimization",
},
"design_domains": [
{"name": "a", "sequence": "N10"},
@ -2104,6 +2307,7 @@ DESIGN_TUBE_EXAMPLE_PAYLOAD = {
"seed": 1,
"wobble_mutations": False,
"max_time_seconds": 0,
"fixed_target_policy": "exclude_from_optimization",
},
"design_domains": [
{"name": "a", "sequence": "N10"},
@ -2329,7 +2533,7 @@ class AppHandler(BaseHTTPRequestHandler):
if parsed.path.startswith("/api/shares/"):
share_id = parsed.path.rsplit("/", 1)[-1]
share = ACCOUNT_STORE.resolve_share(share_id) or get_share(share_id)
share = public_share_payload(share_id, record_access=(parse_qs(parsed.query).get("poll") or ["0"])[0] != "1")
if share is None:
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
return