Icon representing a recipe

Recipe: Manage Bands v1.5

created by Serca

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

Comments