Profile
- Name
- Manage Bands v1.5
- ID
- 109234
- Shared with
- Public
- Parent
- Manage Bands v1.0
- Children
- None
- Created on
- November 07, 2025 at 09:57 AM UTC
- Updated on
- November 09, 2025 at 13:56 PM UTC
- Description
Recipe to manage bands and group of bands
Best for
Code
--[[-- .
v1.5 manual band picker:
- Now you can select bands manually by deleting/changing them while Options Dialog is active and pressing 'Pick Bands'.
v1.0 - recipe to manage bands. It's a bit less friendly than Band Equalizer (https://fold.it/recipes/46757), but has more features.
]]--
version = "1.5"
-------------------------
-- Utilities
-------------------------
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
local function fmt(n, prec)
prec = prec or 3
if n == nil then return "nil" end
return string.format("%." .. tostring(prec) .. "f", n)
end
local function has_band_api()
return band and band.GetCount and band.GetResidueBase and band.GetResidueEnd
end
local function safe_pcall(f, ...)
if type(f) ~= "function" then return false, nil end
return pcall(f, ...)
end
-- Stable, index-independent band identity key based on endpoints (residue/atom pairs),
-- canonicalized to be orientation-invariant. Used for robust matching across deletions
-- that shift indices. Returns a string key.
local function band_key(info)
if not info then return "" end
local rb = tonumber(info.residueBase or 0) or 0
local re = tonumber(info.residueEnd or 0) or 0
local ab = tonumber(info.atomBase or 0) or 0
local ae = tonumber(info.atomEnd or 0) or 0
local l_rb, l_ab, r_re, r_ae = rb, ab, re, ae
if (rb > re) or (rb == re and ab > ae) then
l_rb, l_ab, r_re, r_ae = re, ae, rb, ab
end
return string.format("%d|%d|%d|%d", l_rb, l_ab, r_re, r_ae)
end
-- Simple difference metric between two band infos (ignores index),
-- used to pair items with the same key and detect changes.
local function band_diff_metric(a, b)
if not a or not b then return math.huge end
local function n(x) return tonumber(x or 0) or 0 end
local d = 0.0
d = d + math.abs(n(a.strength) - n(b.strength))
d = d + math.abs(n(a.goalLength) - n(b.goalLength))
if (a.isEnabled and not b.isEnabled) or ((not a.isEnabled) and b.isEnabled) then d = d + 1.0 end
return d
end
-- Build multimap: key -> { items = {...}, used = {false...} }
local function build_keyed_map(items)
local map = {}
for _, it in ipairs(items or {}) do
local k = band_key(it)
local m = map[k]
if not m then m = { items = {}, used = {} } ; map[k] = m end
m.items[#m.items+1] = it
m.used[#m.used+1] = false
end
return map
end
-- Greedy pairing for items with same key: for each current item, find best baseline match
-- by metric; leftovers in baseline are deleted; leftovers in current are added.
local function diff_bands(baseline, current, change_eps)
change_eps = change_eps or 1e-6
local base_map = build_keyed_map(baseline or {})
local curr_map = build_keyed_map(current or {})
local changed = {}
local added = {}
local deleted = {}
-- For keys present in current, match against baseline
for k, cur in pairs(curr_map) do
local bset = base_map[k]
if bset then
for ci, citem in ipairs(cur.items) do
local best_j, best_d = nil, math.huge
for bj, bitem in ipairs(bset.items) do
if not bset.used[bj] then
local d = band_diff_metric(bitem, citem)
if d < best_d then best_d, best_j = d, bj end
end
end
if best_j then
bset.used[best_j] = true
local bitem = bset.items[best_j]
if best_d > change_eps then
changed[#changed+1] = { before = bitem, after = citem, dist = best_d }
end
else
added[#added+1] = citem
end
end
else
-- Entire key is new: all are added
for _, citem in ipairs(cur.items) do added[#added+1] = citem end
end
end
-- Any baseline items not matched are deleted
for k, bset in pairs(base_map) do
for bj, bitem in ipairs(bset.items) do
if not bset.used[bj] then
deleted[#deleted+1] = bitem
end
end
end
return { changed = changed, added = added, deleted = deleted }
end
-- Restore deleted bands from their baseline info. Only segment/sidechain bands (rb,re>0)
-- can be restored with available API. Returns number created.
local function restore_deleted_bands(deleted)
if not deleted or #deleted == 0 then return 0 end
local created = 0
undo.SetUndo(false)
for _, info in ipairs(deleted) do
local rb = tonumber(info.residueBase or 0) or 0
local re = tonumber(info.residueEnd or 0) or 0
local ab = tonumber(info.atomBase or 0) or 0
local ae = tonumber(info.atomEnd or 0) or 0
if rb > 0 and re > 0 and band.AddBetweenSegments then
local id = band.AddBetweenSegments(rb, re, ab, ae)
if info.strength and band.SetStrength then pcall(band.SetStrength, id, info.strength) end
-- Restore GOAL length explicitly (do not use actual length)
local target_len = info.goalLength
if target_len and band.SetGoalLength then pcall(band.SetGoalLength, id, target_len) end
if info.isEnabled == false and band.Disable then pcall(band.Disable, id) end
created = created + 1
else
-- Can't restore Bands in Space without specific API.
end
end
undo.SetUndo(true)
return created
end
-- Try to get actual band length if available.
local function safe_get_actual_length(i)
if band and band.GetLength then
local ok, val = pcall(band.GetLength, i)
if ok and type(val) == "number" then return val end
end
if band and band.GetGoalLength then
local ok, val = pcall(band.GetGoalLength, i)
if ok and type(val) == "number" then return val end
end
-- Fallback for segment-to-segment bands: use residue distance
if band and structure and structure.GetDistance then
local rb = band.GetResidueBase(i)
local re = band.GetResidueEnd(i)
if rb and re and rb > 0 and re > 0 then
local ok, dist = pcall(structure.GetDistance, rb, re)
if ok and type(dist) == "number" then return dist end
end
end
return nil
end
-- Sidechain detection: a band is considered sidechain if it references atoms
-- (non-zero atom indices), except the CA-CA case (atom index == 2 on both ends),
-- which should be treated as a segment-to-segment band.
local function is_sidechain(i)
if not band or not band.GetAtomBase or not band.GetAtomEnd then return false end
local ab = band.GetAtomBase(i) or 0
local ae = band.GetAtomEnd(i) or 0
if ab == 2 and ae == 2 then return false end
return (ab > 0) or (ae > 0)
end
local function band_enabled(i)
if band and band.IsEnabled then
local ok, val = pcall(band.IsEnabled, i)
if ok then return val and true or false end
end
-- Assume enabled if API not available
return true
end
-- Amino-acid mapping and helpers
local aa3_map = {
g = "Gly", a = "Ala", v = "Val", l = "Leu", i = "Ile",
m = "Met", f = "Phe", w = "Trp", p = "Pro", s = "Ser",
t = "Thr", c = "Cys", y = "Tyr", n = "Asn", q = "Gln",
d = "Asp", e = "Glu", k = "Lys", r = "Arg", h = "His",
}
local function get_aa3(i)
if not structure or not structure.GetAminoAcid or not i or i <= 0 then return "Unk" end
local ok, k = pcall(structure.GetAminoAcid, i)
if not ok or not k then return "Unk" end
k = string.lower(tostring(k))
return aa3_map[k] or "Unk"
end
local function seg_label(b)
local rb, re = b.residueBase or 0, b.residueEnd or 0
local ab, ae = b.atomBase or 0, b.atomEnd or 0
local aaB, aaE = get_aa3(rb), get_aa3(re)
-- Avoid '>' symbol to prevent HTML rendering issues in some UIs
return string.format("seg %d(%s)[%d] to %d(%s)[%d]", rb, aaB, ab, re, aaE, ae)
end
local function get_band_info(i)
local info = {
index = i,
residueBase = band.GetResidueBase and band.GetResidueBase(i) or 0,
residueEnd = band.GetResidueEnd and band.GetResidueEnd(i) or 0,
atomBase = band.GetAtomBase and band.GetAtomBase(i) or 0,
atomEnd = band.GetAtomEnd and band.GetAtomEnd(i) or 0,
strength = band.GetStrength and band.GetStrength(i) or nil,
goalLength = band.GetGoalLength and band.GetGoalLength(i) or nil,
actualLength= safe_get_actual_length(i),
isSidechain = is_sidechain(i),
isBiS = false,
isEnabled = band_enabled(i),
}
-- BiS detection: if any residue index is <= 0, treat as Bands in Space
if (info.residueBase or 0) <= 0 or (info.residueEnd or 0) <= 0 then
info.isBiS = true
info.isSidechain = false
end
return info
end
local function gather_all_bands()
local count = band.GetCount()
local list = {}
for i = 1, count do
list[#list + 1] = get_band_info(i)
end
return list
end
-- Compute global ranges for filters and actions
local function compute_ranges(items)
local smin, smax = math.huge, -math.huge
local lmin, lmax = math.huge, -math.huge
for _, b in ipairs(items) do
if b.strength ~= nil then
if b.strength < smin then smin = b.strength end
if b.strength > smax then smax = b.strength end
end
if b.goalLength ~= nil then
if b.goalLength < lmin then lmin = b.goalLength end
if b.goalLength > lmax then lmax = b.goalLength end
end
end
if smin == math.huge then smin, smax = 0.0, 0.0 end
if lmin == math.huge then lmin, lmax = 0.0, 0.0 end
return { smin = smin, smax = smax, lmin = lmin, lmax = lmax }
end
-- Build groups by (strength, length), both rounded to 3 decimals, sorted by strength then length
local function roundfmt(x)
if x == nil then return "nil" end
return string.format("%.3f", x)
end
local function compute_groups(items)
-- Build groups separately for enabled and disabled bands.
-- For each status:
-- 1) Build exact-value groups (rounded to 0.001) and keep only those with count >= 2.
-- 2) Collect remaining singletons and bin them into up to 10 ranges (equal width).
-- Concatenate in order: Enabled S exact -> Enabled S ranges -> Enabled L exact -> Enabled L ranges ->
-- Disabled S exact -> Disabled S ranges -> Disabled L exact -> Disabled L ranges
local function build_groups_for(arr, status_label)
local s_map = {}
local l_map = {}
local s_list, l_list = {}, {}
local s_singles, l_singles = {}, {}
local smin, smax = math.huge, -math.huge
local lmin, lmax = math.huge, -math.huge
for _, b in ipairs(arr) do
if b.strength ~= nil then
local s_str = roundfmt(b.strength)
local s_val = tonumber(s_str) or b.strength
if s_val < smin then smin = s_val end
if s_val > smax then smax = s_val end
local g = s_map[s_str]
if not g then
g = { kind='S', key=s_str, val=s_val, items={}, status=status_label }
s_map[s_str] = g
s_list[#s_list+1] = g
end
g.items[#g.items+1] = b
end
if b.goalLength ~= nil then
local l_str = roundfmt(b.goalLength)
local l_val = tonumber(l_str) or b.goalLength
if l_val < lmin then lmin = l_val end
if l_val > lmax then lmax = l_val end
local g2 = l_map[l_str]
if not g2 then
g2 = { kind='L', key=l_str, val=l_val, items={}, status=status_label }
l_map[l_str] = g2
l_list[#l_list+1] = g2
end
g2.items[#g2.items+1] = b
end
end
-- Split exact (count>=2) vs singletons (count==1)
local s_exact, l_exact = {}, {}
for _, g in ipairs(s_list) do
if #g.items >= 2 then s_exact[#s_exact+1] = g else for _, it in ipairs(g.items) do s_singles[#s_singles+1] = it end end
end
for _, g in ipairs(l_list) do
if #g.items >= 2 then l_exact[#l_exact+1] = g else for _, it in ipairs(g.items) do l_singles[#l_singles+1] = it end end
end
table.sort(s_exact, function(a,b) return a.val < b.val end)
table.sort(l_exact, function(a,b) return a.val < b.val end)
-- Bin singletons into up to 10 ranges
local function bin_singletons(kind, arr2, vmin, vmax, get_val)
if #arr2 == 0 then return {} end
local count = 10
local width = (vmax - vmin) / count
if width <= 0 then
local g = { kind = kind, isRange = true, lo = vmin, hi = vmax, key = roundfmt(vmin) .. ".." .. roundfmt(vmax), items = {}, status=status_label }
for _, it in ipairs(arr2) do g.items[#g.items+1] = it end
return { g }
end
local bins = {}
for i=1,count do
local lo = vmin + (i-1)*width
local hi = (i==count) and vmax or (vmin + i*width)
bins[i] = { kind = kind, isRange = true, lo = lo, hi = hi, key = roundfmt(lo) .. ".." .. roundfmt(hi), items = {}, status=status_label }
end
for _, it in ipairs(arr2) do
local val = get_val(it)
local idx = math.floor(((val - vmin) / width) + 1)
if idx < 1 then idx = 1 end
if idx > count then idx = count end
table.insert(bins[idx].items, it)
end
local compact = {}
for i=1,count do
if #bins[i].items > 0 then compact[#compact+1] = bins[i] end
end
return compact
end
-- Compute singles min/max separately to bin only singles span
local smin_single, smax_single = math.huge, -math.huge
for _, it in ipairs(s_singles) do
local v = it.strength or 0
if v < smin_single then smin_single = v end
if v > smax_single then smax_single = v end
end
if smin_single == math.huge then smin_single, smax_single = smin, smax end
local lmin_single, lmax_single = math.huge, -math.huge
for _, it in ipairs(l_singles) do
local v = it.goalLength or 0
if v < lmin_single then lmin_single = v end
if v > lmax_single then lmax_single = v end
end
if lmin_single == math.huge then lmin_single, lmax_single = lmin, lmax end
local s_ranges = bin_singletons('S', s_singles, smin_single, smax_single, function(it) return it.strength or 0 end)
local l_ranges = bin_singletons('L', l_singles, lmin_single, lmax_single, function(it) return it.goalLength or 0 end)
local groups = {}
for _, g in ipairs(s_exact) do groups[#groups+1] = g end
for _, g in ipairs(s_ranges) do groups[#groups+1] = g end
for _, g in ipairs(l_exact) do groups[#groups+1] = g end
for _, g in ipairs(l_ranges) do groups[#groups+1] = g end
return groups
end
local enabled_items, disabled_items = {}, {}
for _, b in ipairs(items) do
if b.isEnabled then enabled_items[#enabled_items+1] = b else disabled_items[#disabled_items+1] = b end
end
local groups_all = {}
local en_groups = build_groups_for(enabled_items, 'enabled')
for _, g in ipairs(en_groups) do groups_all[#groups_all+1] = g end
local dis_groups = build_groups_for(disabled_items, 'disabled')
for _, g in ipairs(dis_groups) do groups_all[#groups_all+1] = g end
for i, g in ipairs(groups_all) do g.id = i end
return groups_all
end
local function print_groups(groups)
print("Groups by status: exact equals (count>=2) by Strength [S] and Length [L]; singles are binned into ranges:")
if not groups or #groups == 0 then
print(" (no groups)")
return
end
-- Helper to compute counterpart range label
local function counterpart_range(g)
local mn, mx
if g.kind == 'S' then
for _, it in ipairs(g.items) do
local v = it.goalLength
if v ~= nil then
mn = (mn == nil or v < mn) and v or mn
mx = (mx == nil or v > mx) and v or mx
end
end
if mn == nil then return "" end
return string.format(" | L=%s..%s", roundfmt(mn), roundfmt(mx))
else
for _, it in ipairs(g.items) do
local v = it.strength
if v ~= nil then
mn = (mn == nil or v < mn) and v or mn
mx = (mx == nil or v > mx) and v or mx
end
end
if mn == nil then return "" end
return string.format(" | S=%s..%s", roundfmt(mn), roundfmt(mx))
end
end
local en_s_lines, en_l_lines, dis_s_lines, dis_l_lines = {}, {}, {}, {}
for _, g in ipairs(groups) do
local status_tag = (g.status == 'disabled') and "[DIS] " or "[EN] "
local prefix = status_tag .. ((g.kind == 'S') and "[S] S=" or "[L] L=")
local label = g.key
if g.isRange then
if #g.items == 1 then
local it = g.items[1]
label = (g.kind == 'S') and roundfmt(it.strength) or roundfmt(it.goalLength)
else
-- For range groups with multiple items, show actual min..max across items
local mn, mx
if g.kind == 'S' then
for _, it in ipairs(g.items) do
local v = it.strength
if v ~= nil then
mn = (mn == nil or v < mn) and v or mn
mx = (mx == nil or v > mx) and v or mx
end
end
else
for _, it in ipairs(g.items) do
local v = it.goalLength
if v ~= nil then
mn = (mn == nil or v < mn) and v or mn
mx = (mx == nil or v > mx) and v or mx
end
end
end
if mn ~= nil and mx ~= nil then
if math.abs(mx - mn) < 1e-12 then
label = roundfmt(mn)
else
label = roundfmt(mn) .. ".." .. roundfmt(mx)
end
end
end
end
local tail = counterpart_range(g)
local line = string.format(" #%d %s%s%s | count=%d", g.id, prefix, label, tail, #g.items)
if g.status == 'disabled' then
if g.kind == 'S' then dis_s_lines[#dis_s_lines+1] = line else dis_l_lines[#dis_l_lines+1] = line end
else
if g.kind == 'S' then en_s_lines[#en_s_lines+1] = line else en_l_lines[#en_l_lines+1] = line end
end
end
if #en_s_lines + #en_l_lines > 0 then
print("Enabled:")
for _, line in ipairs(en_s_lines) do print(line) end
if #en_l_lines > 0 then print("-") end
for _, line in ipairs(en_l_lines) do print(line) end
end
if #dis_s_lines + #dis_l_lines > 0 then
if #en_s_lines + #en_l_lines > 0 then print("=") end
print("Disabled:")
for _, line in ipairs(dis_s_lines) do print(line) end
if #dis_l_lines > 0 then print("-") end
for _, line in ipairs(dis_l_lines) do print(line) end
end
end
local function update_state(cfg)
local items = gather_all_bands()
cfg.items = items
cfg.ranges = compute_ranges(items)
cfg.groups = compute_groups(items)
-- Append manual group (from "Pick Bands") if present
if cfg.manual_group_items and #cfg.manual_group_items > 0 then
local g = { kind = 'M', key = 'Pick', items = {}, status = 'mixed' }
for _, it in ipairs(cfg.manual_group_items) do g.items[#g.items+1] = it end
cfg.groups[#cfg.groups+1] = g
end
for i, g in ipairs(cfg.groups) do g.id = i end
-- Initialize default filter ranges to the full existing ranges, once
if not cfg.defaults_set then
cfg.strength_min = cfg.ranges.smin
cfg.strength_max = cfg.ranges.smax
cfg.length_min = cfg.ranges.lmin
cfg.length_max = cfg.ranges.lmax
cfg.defaults_set = true
end
end
-------------------------
-- Reporting
-------------------------
local function stats(values)
local n = #values
if n == 0 then return {n=0, min=nil, max=nil, avg=nil} end
local mn, mx = values[1], values[1]
local sum = 0
for i=1,n do
local v = values[i]
if v ~= nil then
if v < mn then mn = v end
if v > mx then mx = v end
sum = sum + v
end
end
return { n = n, min = mn, max = mx, avg = sum / n }
end
-- Histograms removed per new requirements
local function print_group_stats(group_name, arr)
local strengths, lengths = {}, {}
for _, b in ipairs(arr) do
if b.strength ~= nil then strengths[#strengths+1] = b.strength end
if b.goalLength ~= nil then lengths[#lengths+1] = b.goalLength end
end
local sst = stats(strengths)
local lst = stats(lengths)
print(string.format("%s | count=%d", group_name, #arr))
print(string.format(" Strengths: n=%d, min=%s, max=%s, avg=%s",
sst.n, fmt(sst.min), fmt(sst.max), fmt(sst.avg)))
print(string.format(" Lengths: n=%d, min=%s, max=%s, avg=%s",
lst.n, fmt(lst.min), fmt(lst.max), fmt(lst.avg)))
end
local function print_report(cfg)
local count = band.GetCount()
if count == 0 then
print("No bands found.")
return
end
local items = gather_all_bands()
print(string.format("Bands total: %d", #items))
local seg_enabled, seg_disabled, side_enabled, side_disabled, bis_enabled, bis_disabled = {}, {}, {}, {}, {}, {}
for _, b in ipairs(items) do
if b.isBiS then
if b.isEnabled then bis_enabled[#bis_enabled+1] = b else bis_disabled[#bis_disabled+1] = b end
elseif b.isSidechain then
if b.isEnabled then side_enabled[#side_enabled+1] = b else side_disabled[#side_disabled+1] = b end
else
if b.isEnabled then seg_enabled[#seg_enabled+1] = b else seg_disabled[#seg_disabled+1] = b end
end
end
print_group_stats("Segment bands (enabled)", seg_enabled)
print_group_stats("Segment bands (disabled)", seg_disabled)
print_group_stats("Sidechain bands (enabled)", side_enabled)
print_group_stats("Sidechain bands (disabled)", side_disabled)
print_group_stats("BiS bands (enabled)", bis_enabled)
print_group_stats("BiS bands (disabled)", bis_disabled)
-- Histograms removed
-- Show top extremes
table.sort(items, function(a,b)
local la = a.goalLength or math.huge
local lb = b.goalLength or math.huge
if la == lb then return (a.index or 0) < (b.index or 0) end
return la < lb
end)
print("\nShortest bands (up to 5):")
for i=1, math.min(5, #items) do
local b = items[i]
local t = b.isBiS and "BiS" or (b.isSidechain and "sidechain" or "segment")
print(string.format(
" #%d %s | goal=%s L=%s | S=%s | %s | %s",
b.index, seg_label(b),
fmt(b.goalLength), fmt(b.actualLength), fmt(b.strength), t,
b.isEnabled and "enabled" or "disabled"
))
end
table.sort(items, function(a,b)
local la = a.goalLength or -math.huge
local lb = b.goalLength or -math.huge
if la == lb then return (a.index or 0) < (b.index or 0) end
return la > lb
end)
print("\nLongest bands (up to 5):")
for i=1, math.min(5, #items) do
local b = items[i]
local t = b.isBiS and "BiS" or (b.isSidechain and "sidechain" or "segment")
print(string.format(
" #%d %s | goal=%s L=%s | S=%s | %s | %s",
b.index, seg_label(b),
fmt(b.goalLength), fmt(b.actualLength), fmt(b.strength), t,
b.isEnabled and "enabled" or "disabled"
))
end
print("\nAll bands (full list):")
table.sort(items, function(a,b) return (a.index or 0) < (b.index or 0) end)
for _, b in ipairs(items) do
local t = b.isBiS and "BiS" or (b.isSidechain and "sidechain" or "segment")
print(string.format(
" #%d type=%s status=%s | S=%s goal=%s L=%s | %s",
b.index, t,
b.isEnabled and "enabled" or "disabled",
fmt(b.strength), fmt(b.goalLength), fmt(b.actualLength),
seg_label(b)
))
end
end
-------------------------
-- Filtering and actions
-------------------------
local EPS = 1e-6
local function passes_filters(b, cfg)
-- Type filter
if b.isBiS and not cfg.include_bis then return false end
if (not b.isBiS) then
if b.isSidechain and not cfg.include_sidechain then return false end
if (not b.isSidechain) and not cfg.include_segment then return false end
end
-- Enabled filter
if b.isEnabled and not cfg.include_enabled then return false end
if (not b.isEnabled) and not cfg.include_disabled then return false end
-- Strength range (always on; treat missing values as pass-through)
local s = b.strength
if s ~= nil then
if cfg.strength_min and s < cfg.strength_min - EPS then return false end
if cfg.strength_max and s > cfg.strength_max + EPS then return false end
end
-- Length range (uses goal length; always on; treat missing as pass-through)
local L = b.goalLength
if L ~= nil then
if cfg.length_min and L < cfg.length_min - EPS then return false end
if cfg.length_max and L > cfg.length_max + EPS then return false end
end
return true
end
local function select_bands(cfg)
local base = {}
if cfg.group_index and cfg.group_index > 0 and cfg.groups and cfg.groups[cfg.group_index] then
base = cfg.groups[cfg.group_index].items
else
base = gather_all_bands()
end
local out = {}
for _, b in ipairs(base) do
if passes_filters(b, cfg) then out[#out+1] = b end
end
return out
end
-- Action types
-- Actions are configured via checkboxes and two sliders now
local function apply_action(selection, cfg)
local n = #selection
if n == 0 then
print("No bands match the current filters — nothing to apply.")
return
end
local updated = 0
local function fetch_current_strength(idx, fallback)
if band and band.GetStrength then
local ok, val = pcall(band.GetStrength, idx)
if ok and type(val) == "number" then return val end
end
return fallback
end
local function fetch_current_length(idx, fallback)
if band and band.GetGoalLength then
local ok, val = pcall(band.GetGoalLength, idx)
if ok and type(val) == "number" then return val end
end
return fallback
end
local function target_strength(idx, binfo)
local val = cfg.action_strength_value
if val == nil then return nil end
if cfg.act_proportional then
local current = fetch_current_strength(idx, binfo.strength)
if current ~= nil then
return clamp(current * val, 0.0, 10.0)
end
end
return clamp(val, 0.0, 10.0)
end
local function target_length(idx, binfo)
local val = cfg.action_length_value
if val == nil then return nil end
if cfg.act_proportional then
local current = fetch_current_length(idx, binfo.goalLength)
if current ~= nil then
return math.max(0.01, current * val)
end
end
return math.max(0.01, val)
end
undo.SetUndo(false)
for _, b in ipairs(selection) do
local i = b.index
-- Set Strength
if cfg.act_set_strength and band.SetStrength and cfg.action_strength_value ~= nil then
local newv = target_strength(i, b)
if newv ~= nil then
band.SetStrength(i, newv)
updated = updated + 1
end
end
-- Set Length (goal)
if cfg.act_set_length and band.SetGoalLength and cfg.action_length_value ~= nil then
local L = target_length(i, b)
if L ~= nil then
band.SetGoalLength(i, L)
updated = updated + 1
end
end
-- Set Goal Length = Actual length via band.GetLength
if cfg.act_len_to_actual and band.SetGoalLength and band.GetLength then
local okL, L = pcall(band.GetLength, i)
if okL and type(L) == 'number' and L > 0 then
band.SetGoalLength(i, L)
updated = updated + 1
end
end
-- Enable/Disable
if cfg.act_enable and band.Enable then
pcall(band.Enable, i)
updated = updated + 1
end
if cfg.act_disable and band.Disable then
pcall(band.Disable, i)
updated = updated + 1
end
end
undo.SetUndo(true)
-- Fallback: only if no actions at all are checked
-- If nothing was updated and values changed vs remembered defaults,
-- auto-apply those changed values (warn user).
local no_actions_checked = not (
(cfg.act_set_strength or false) or
(cfg.act_set_length or false) or
(cfg.act_len_to_actual or false) or
(cfg.act_enable or false) or
(cfg.act_disable or false) or
(cfg.act_remove or false)
)
if updated == 0 and n > 0 and no_actions_checked then
local warn_msgs = {}
local did_any = false
local strength_changed = (cfg.action_strength_value ~= nil and cfg.def_strength_value ~= nil and math.abs(cfg.action_strength_value - cfg.def_strength_value) > EPS)
local length_changed = (cfg.action_length_value ~= nil and cfg.def_length_value ~= nil and math.abs(cfg.action_length_value - cfg.def_length_value) > EPS)
if (strength_changed or length_changed) then
undo.SetUndo(false)
for _, b in ipairs(selection) do
local i = b.index
if strength_changed and band.SetStrength then
local newv = target_strength(i, b)
if newv ~= nil then
band.SetStrength(i, newv)
updated = updated + 1
did_any = true
end
end
if length_changed and band.SetGoalLength then
local L = target_length(i, b)
if L ~= nil then
band.SetGoalLength(i, L)
updated = updated + 1
did_any = true
end
end
end
undo.SetUndo(true)
if strength_changed then warn_msgs[#warn_msgs+1] = string.format("Strength default changed: %s to %s", fmt(cfg.def_strength_value), fmt(cfg.action_strength_value)) end
if length_changed then warn_msgs[#warn_msgs+1] = string.format("Length default changed: %s to %s", fmt(cfg.def_length_value), fmt(cfg.action_length_value)) end
if did_any then
print("Note: No actions were checked. Applied changed defaults to selection:")
for _, m in ipairs(warn_msgs) do print(" - " .. m) end
end
end
end
local removed, failed = 0, 0
if cfg.act_remove and band.Delete then
-- Delete after applying other actions; use descending order
table.sort(selection, function(a,b) return a.index > b.index end)
undo.SetUndo(false)
for _, b in ipairs(selection) do
local okd = pcall(band.Delete, b.index)
if okd then removed = removed + 1 else failed = failed + 1 end
end
undo.SetUndo(true)
end
-- Remember current action values as new defaults
if cfg.action_strength_value ~= nil then
cfg.def_strength_value = cfg.action_strength_value
end
if cfg.action_length_value ~= nil then
cfg.def_length_value = cfg.action_length_value
end
if cfg.act_remove then
print(string.format("Applied changes to %d bands (selected=%d). Removed=%d, failed=%d", updated, n, removed, failed))
else
print(string.format("Applied changes to %d bands (selected=%d)", updated, n))
end
end
local function preview_selection(selection)
print(string.format("Preview: %d bands selected.", #selection))
for _, b in ipairs(selection) do
local t = b.isBiS and "BiS" or (b.isSidechain and "sidechain" or "segment")
print(string.format(
" #%d %s | L=%s | S=%s | %s | %s",
b.index, seg_label(b),
fmt(b.actualLength), fmt(b.strength), t,
b.isEnabled and "enabled" or "disabled"
))
end
end
-- Export selected bands to compact string (for copy/paste) and pretty list
local function serialize_compact(tbl)
if not tbl or #tbl == 0 then return "" end
local out = {"{"}
for i, b in ipairs(tbl) do
local piece = string.format(
"{rb=%d,re=%d,ab=%d,ae=%d,s=%s,gl=%s,al=%s,en=%s}",
tonumber(b.residueBase or 0) or 0,
tonumber(b.residueEnd or 0) or 0,
tonumber(b.atomBase or 0) or 0,
tonumber(b.atomEnd or 0) or 0,
b.strength ~= nil and string.format("%.6f", b.strength) or "nil",
b.goalLength ~= nil and string.format("%.6f", b.goalLength) or "nil",
b.actualLength ~= nil and string.format("%.6f", b.actualLength) or "nil",
tostring(b.isEnabled ~= false)
)
out[#out + 1] = piece .. (i < #tbl and "," or "")
end
out[#out + 1] = "}"
return table.concat(out)
end
local function export_selection(selection)
print(string.format("Export: %d bands selected.", #selection))
for _, b in ipairs(selection) do
local t = b.isBiS and "BiS" or (b.isSidechain and "sidechain" or "segment")
print(string.format(
" #%d type=%s status=%s | S=%s goal=%s L=%s | %s",
b.index, t,
b.isEnabled and "enabled" or "disabled",
fmt(b.strength), fmt(b.goalLength), fmt(b.actualLength),
seg_label(b)
))
end
local compact = serialize_compact(selection)
if compact ~= "" then
print("\nSerialized (compact):\n" .. compact .. "\n")
end
end
-------------------------
-- Help/Instructions
-------------------------
local function print_help()
print("")
end
-------------------------
-- Manual pick support
-------------------------
-- Build selection for manual group based on baseline and current state.
-- Prioritize restored bands for keys that were deleted by preferring higher indices.
local function build_manual_selection(baseline, now_state, first_diff)
local selection = {}
local used_idx = {}
-- Map now_state by key, with items sorted by index descending (prefer newest)
local now_map = {}
for _, it in ipairs(now_state or {}) do
local k = band_key(it)
local arr = now_map[k]
if not arr then arr = {} ; now_map[k] = arr end
arr[#arr+1] = it
end
for _, arr in pairs(now_map) do
table.sort(arr, function(a,b) return (a.index or 0) > (b.index or 0) end)
end
local function take_n_by_key(k, n)
local arr = now_map[k]
if not arr then return end
for _, it in ipairs(arr) do
if n <= 0 then break end
if not used_idx[it.index] then
selection[#selection+1] = it
used_idx[it.index] = true
n = n - 1
end
end
end
-- 1) Include restored (deleted) bands by key/count
local del_count = {}
for _, b in ipairs(first_diff and first_diff.deleted or {}) do
local k = band_key(b)
del_count[k] = (del_count[k] or 0) + 1
end
for k, n in pairs(del_count) do take_n_by_key(k, n) end
-- 2) Include changed (after) and added bands in current-now state
local post = diff_bands(baseline or {}, now_state or {}, 1e-6)
for _, ch in ipairs(post.changed) do
local it = ch.after
if it and not used_idx[it.index] then
selection[#selection+1] = it
used_idx[it.index] = true
end
end
for _, ad in ipairs(post.added) do
if ad and not used_idx[ad.index] then
selection[#selection+1] = ad
used_idx[ad.index] = true
end
end
return selection
end
-- Print details for a specific group: id, kind, key, count and item list
local function print_group_details(group)
if not group then return end
local kind = tostring(group.kind or '?')
local status = tostring(group.status or '')
local key = tostring(group.key or '')
local id = tonumber(group.id or 0) or 0
local items = group.items or {}
print(string.format("Group #%d [%s] %s | status=%s | count=%d", id, kind, key, status, #items))
for _, b in ipairs(items) do
local t = b.isBiS and "BiS" or (b.isSidechain and "sidechain" or "segment")
print(string.format(
" #%d type=%s status=%s | S=%s goal=%s L=%s | %s",
b.index, t,
b.isEnabled and "enabled" or "disabled",
fmt(b.strength), fmt(b.goalLength), fmt(b.actualLength),
seg_label(b)
))
end
end
-------------------------
-- Dialog / UI
-------------------------
local function build_dialog(cfg)
local dlg = dialog.CreateDialog("Manage Bands")
-- Group selection
local gcount = (cfg.groups and #cfg.groups) or 0
local grp_val = clamp(cfg.group_index or 0, 0, gcount)
dlg.lblG = dialog.AddLabel(string.format("Groups available: %d", gcount))
if gcount > 0 then
dlg.grp_idx = dialog.AddSlider("Select group (0=all)", grp_val, 0, gcount, 0)
else
dlg.grp_note = dialog.AddLabel("No groups to select (0 = all)")
end
-- Type/status filters
dlg.f_seg = dialog.AddCheckbox("Include seg-seg bands", cfg.include_segment ~= false)
dlg.f_atom = dialog.AddCheckbox("Include sidechain bands", cfg.include_sidechain ~= false)
dlg.f_bis = dialog.AddCheckbox("Include bands in space", cfg.include_bis ~= false)
dlg.f_en = dialog.AddCheckbox("Include enabled bands", cfg.include_enabled ~= false)
dlg.f_dis= dialog.AddCheckbox("Include disabled bands", cfg.include_disabled ~= false)
-- Strength range (always active)
local smin = cfg.ranges and cfg.ranges.smin or 0.0
local smax = cfg.ranges and cfg.ranges.smax or 10.0
if smax < smin then smax = smin end
if not (smax > smin) then
local pad = math.max(0.01, math.abs(smin) * 0.1)
smin = math.max(0.0, smin - pad)
smax = smax + pad
if not (smax > smin) then smax = smin + 0.01 end
end
local s_val_min = clamp(cfg.strength_min or smin, smin, smax)
local s_val_max = clamp(cfg.strength_max or smax, smin, smax)
dlg.f_s_min = dialog.AddSlider("Min strength", s_val_min, smin, smax, 2)
dlg.f_s_max = dialog.AddSlider("Max strength", s_val_max, smin, smax, 2)
-- Length range (always active)
local lmin = cfg.ranges and cfg.ranges.lmin or 0.0
local lmax = cfg.ranges and cfg.ranges.lmax or 10.0
if lmax < lmin then lmax = lmin end
if not (lmax > lmin) then
local pad = math.max(0.01, math.abs(lmin) * 0.1)
lmin = math.max(0.0, lmin - pad)
lmax = lmax + pad
if not (lmax > lmin) then lmax = lmin + 0.01 end
end
local l_val_min = clamp(cfg.length_min or lmin, lmin, lmax)
local l_val_max = clamp(cfg.length_max or lmax, lmin, lmax)
dlg.f_l_min = dialog.AddSlider("Min length", l_val_min, lmin, lmax, 2)
dlg.f_l_max = dialog.AddSlider("Max length", l_val_max, lmin, lmax, 2)
-- Action config via checkboxes
dlg.lblAct = dialog.AddLabel("Actions:")
dlg.act_s_on = dialog.AddCheckbox("Set strength", cfg.act_set_strength or false)
local act_s_val = clamp(cfg.action_strength_value or 1.0, 0.0, 100.0)
dlg.act_s_val = dialog.AddSlider("Strength value", act_s_val, 0.0, 10.0, 2)
local l_upper = math.max((cfg.ranges and cfg.ranges.lmax or 0.0) * 10.0, 10.0)
dlg.act_l_on = dialog.AddCheckbox("Set length", cfg.act_set_length or false)
local act_l_val = clamp(cfg.action_length_value or 2.0, 0.0, l_upper)
dlg.act_l_val = dialog.AddSlider("Length value", act_l_val, 0.0, l_upper, 2)
dlg.act_en = dialog.AddCheckbox("Enable", cfg.act_enable or false)
dlg.act_dis = dialog.AddCheckbox("Disable", cfg.act_disable or false)
dlg.act_rm = dialog.AddCheckbox("Remove", cfg.act_remove or false)
dlg.act_l_actual = dialog.AddCheckbox("Length = actual", cfg.act_len_to_actual or false)
dlg.act_prop = dialog.AddCheckbox("Proportional change", cfg.act_proportional or false)
-- Buttons (order per request)
dlg.btn_exit = dialog.AddButton("Exit", 0)
dlg.btn_report = dialog.AddButton("Report", 10)
dlg.btn_pick = dialog.AddButton("Pick Bands", 20)
-- Preview button removed; preview output is shown in Report
dlg.btn_apply = dialog.AddButton("Apply", 12)
return dlg
end
local function read_dialog(dlg, cfg)
cfg = cfg or {}
-- Type/status
cfg.include_segment = dlg.f_seg and dlg.f_seg.value or false
cfg.include_sidechain = dlg.f_atom and dlg.f_atom.value or false
cfg.include_bis = dlg.f_bis and dlg.f_bis.value or false
cfg.include_enabled = dlg.f_en and dlg.f_en.value or false
cfg.include_disabled = dlg.f_dis and dlg.f_dis.value or false
-- Strength
cfg.strength_min = dlg.f_s_min and dlg.f_s_min.value or cfg.strength_min
cfg.strength_max = dlg.f_s_max and dlg.f_s_max.value or cfg.strength_max
if cfg.strength_min and cfg.strength_max and cfg.strength_min > cfg.strength_max then
cfg.strength_min, cfg.strength_max = cfg.strength_max, cfg.strength_min
end
-- Length
cfg.length_min = dlg.f_l_min and dlg.f_l_min.value or cfg.length_min
cfg.length_max = dlg.f_l_max and dlg.f_l_max.value or cfg.length_max
if cfg.length_min and cfg.length_max and cfg.length_min > cfg.length_max then
cfg.length_min, cfg.length_max = cfg.length_max, cfg.length_min
end
-- Group
cfg.group_index = math.floor(dlg.grp_idx and dlg.grp_idx.value or 0)
-- Actions
cfg.act_set_strength = dlg.act_s_on and dlg.act_s_on.value or false
cfg.action_strength_value = dlg.act_s_val and dlg.act_s_val.value or cfg.action_strength_value
cfg.act_set_length = dlg.act_l_on and dlg.act_l_on.value or false
cfg.action_length_value = dlg.act_l_val and dlg.act_l_val.value or cfg.action_length_value
cfg.act_len_to_actual = dlg.act_l_actual and dlg.act_l_actual.value or false
cfg.act_enable = dlg.act_en and dlg.act_en.value or false
cfg.act_disable = dlg.act_dis and dlg.act_dis.value or false
cfg.act_remove = dlg.act_rm and dlg.act_rm.value or false
cfg.act_proportional = dlg.act_prop and dlg.act_prop.value or false
return cfg
end
-------------------------
-- Main loop
-------------------------
local function main()
if not has_band_api() then
print("Band API is not available in this environment.")
return
end
undo.SetUndo(false)
local cfg = {
-- Filters defaults
include_segment = true,
include_sidechain = true,
include_bis = true,
include_enabled = true,
include_disabled = true,
strength_min = nil,
strength_max = nil,
length_min = nil,
length_max = nil,
-- Group & action defaults
group_index = 0,
action_strength_value = 1.0,
action_length_value = 2.0,
-- Remember last applied defaults for auto-apply fallback
def_strength_value = 1.0,
def_length_value = 2.0,
act_proportional = false,
}
-- Initial state and prints
update_state(cfg)
-- Snapshot initial bands for manual pick comparison
cfg.initial_snapshot = gather_all_bands()
print_help()
print("")
print_groups(cfg.groups)
while true do
update_state(cfg)
print("You can pick specific bands. Remove any bands while dialog is active and press 'Pick Bands' button. \nAll deleted bands will be restored\n and a new band group will be built and selected.")
local dlg = build_dialog(cfg)
local rv = dialog.Show(dlg)
cfg = read_dialog(dlg, cfg)
if rv == 0 then
print("Exit requested by user.")
return
elseif rv == 10 then
-- Show preview of current selection before report
local sel = select_bands(cfg)
if cfg.group_index and cfg.group_index > 0 and cfg.groups and cfg.groups[cfg.group_index] then
print_group_details(cfg.groups[cfg.group_index])
end
preview_selection(sel)
print_report(cfg)
elseif rv == 12 then
local sel = select_bands(cfg)
apply_action(sel, cfg)
break
elseif rv == 20 then
-- Manual pick: user may have deleted bands in GUI to indicate selection.
-- Compare current vs initial snapshot, restore deleted, and build a manual group
local baseline = cfg.initial_snapshot or gather_all_bands()
local current = gather_all_bands()
local diff = diff_bands(baseline, current, 1e-6)
local delN, chN, addN = #diff.deleted, #diff.changed, #diff.added
if delN + chN + addN == 0 then
-- Show a small info popup and then immediately re-check after OK
local md = dialog.CreateDialog("Pick Bands")
md.l1 = dialog.AddLabel("To pick bands, delete or change them.")
md.l2 = dialog.AddLabel("Then press 'OK' to continue.")
md.l3 = dialog.AddLabel("A new group will be built.")
md.ok = dialog.AddButton("OK", -1)
dialog.Show(md)
-- Re-check after OK
local current2 = gather_all_bands()
local diff2 = diff_bands(baseline, current2, 1e-6)
local del2, ch2, add2 = #diff2.deleted, #diff2.changed, #diff2.added
if del2 + ch2 + add2 > 0 then
if del2 > 0 then
local created2 = restore_deleted_bands(diff2.deleted)
print(string.format("Pick Bands: restored deleted: %d", created2))
end
local now_state2 = gather_all_bands()
local manual_sel2 = build_manual_selection(baseline, now_state2, diff2)
cfg.manual_group_items = manual_sel2
update_state(cfg)
local gcount2 = (cfg.groups and #cfg.groups) or 0
cfg.group_index = gcount2
print(string.format("Pick Bands: deleted=%d, changed=%d, added=%d; to group: %d", del2, ch2, add2, #manual_sel2))
local grp2 = (cfg.groups and cfg.groups[gcount2]) or nil
if grp2 then
print_group_details(grp2)
end
else
print("Pick Bands: still no differences from baseline.")
end
else
if delN > 0 then
local created = restore_deleted_bands(diff.deleted)
print(string.format("Pick Bands: restored deleted: %d", created))
end
local now_state = gather_all_bands()
local manual_sel = build_manual_selection(baseline, now_state, diff)
cfg.manual_group_items = manual_sel
-- Rebuild groups to compute final manual group id
update_state(cfg)
local gcount = (cfg.groups and #cfg.groups) or 0
cfg.group_index = gcount
print(string.format("Pick Bands: deleted=%d, changed=%d, added=%d; to group: %d",
delN, chN, addN, #manual_sel))
-- Print group details (id and band list)
local grp = (cfg.groups and cfg.groups[gcount]) or nil
if grp then
print_group_details(grp)
end
end
else
-- Unknown button or closed: loop again
end
end
undo.SetUndo(true)
save.Quicksave(100)
save.Quickload(100)
end
-- Entry
main()