Module:Interval edo approximation

From Xenharmonic Wiki
Revision as of 09:10, 3 November 2025 by Pailiaq (talk | contribs)
Jump to navigation Jump to search
Module documentation[view] [edit] [history] [purge]
This module should not be invoked directly; use its corresponding template instead: Template:Interval edo approximation.


Introspection summary for Module:Interval edo approximation 
Functions provided (1)
Line Function Params
115 main (invokable) (frame)
Lua modules required (0)
Variable Module Functions used

No function descriptions were provided. The Lua code may have further information.


-- EDO Approximations Module
-- Calculates EDO approximations for just intervals
-- Usage: {{#invoke:EDO_Approximations|main|interval=3/2|tolerance=9|min_edo=5|max_edo=60}}

local p = {}

-- ===== CONFIGURATION VARIABLES =====
local DEFAULT_TOLERANCE = 9.0  -- Relative error tolerance in percent
local DEFAULT_MIN_EDO = 5      -- Minimum EDO to check
local DEFAULT_MAX_EDO = 60     -- Maximum EDO to check
-- ====================================

-- Convert a frequency ratio to cents
-- Python: return 1200 * math.log2(ratio)
local function cents(ratio)
    -- Lua doesn't have log2, so use change of base formula
    return 1200 * (math.log(ratio) / math.log(2))
end

-- Parse a ratio string like '3/2' into a number
-- Python: parts = ratio_str.strip().split('/'); return float(parts[0]) / float(parts[1])
local function parse_ratio(ratio_str)
    -- Strip whitespace (equivalent to Python's strip())
    ratio_str = ratio_str:match("^%s*(.-)%s*$")

    -- Split on '/' and parse numerator/denominator
    local slash_pos = ratio_str:find("/")
    if not slash_pos then
        return nil
    end

    local num_str = ratio_str:sub(1, slash_pos - 1)
    local denom_str = ratio_str:sub(slash_pos + 1)

    local num = tonumber(num_str)
    local denom = tonumber(denom_str)

    if not num or not denom or denom == 0 then
        return nil
    end

    -- Return exact division (equivalent to Python's float division)
    return num / denom
end

-- Python-compatible rounding function
-- Python's round() uses "round half to even" (banker's rounding)
local function round(x)
    local floor_x = math.floor(x)
    local frac = x - floor_x

    if frac < 0.5 then
        return floor_x
    elseif frac > 0.5 then
        return floor_x + 1
    else
        -- Exactly 0.5: round to nearest even number (banker's rounding)
        if floor_x % 2 == 0 then
            return floor_x
        else
            return floor_x + 1
        end
    end
end

-- Find the best approximation of an interval in a given EDO
-- Python: best_step = round(ratio_cents / edostep)
local function find_best_approximation(ratio_cents, edo)
    local edostep = 1200 / edo
    -- Find the nearest step using Python-compatible rounding
    local best_step = round(ratio_cents / edostep)
    local approximation_cents = best_step * edostep
    local absolute_error = approximation_cents - ratio_cents
    -- Python: relative_error = (absolute_error / edostep) * 100 if edostep != 0 else 0
    local relative_error = (absolute_error / edostep) * 100

    return best_step, absolute_error, relative_error
end

-- Calculate all EDO approximations within tolerance for a given ratio
-- Python: for edo in range(EDO_RANGE_MIN, EDO_RANGE_MAX + 1):
--           if abs(rel_error) <= RELATIVE_ERROR_TOLERANCE:
local function calculate_edo_approximations(ratio, tolerance, min_edo, max_edo)
    local ratio_cents = cents(ratio)
    local results = {}

    -- Loop through EDO range (inclusive on both ends, like Python's range)
    for edo = min_edo, max_edo do
        local steps, abs_error, rel_error = find_best_approximation(ratio_cents, edo)

        -- Filter by tolerance using absolute value
        if math.abs(rel_error) <= tolerance then
            table.insert(results, {
                edo = edo,
                steps = steps,
                abs_error = abs_error,
                rel_error = rel_error
            })
        end
    end

    return results
end

-- Format a number with sign and 2 decimal places
local function format_error(value)
    if value >= 0 then
        return string.format("+%.2f", value)
    else
        return string.format("%.2f", value)
    end
end

-- Main function to generate the wikitable
function p.main(frame)
    -- Get parameters from template invocation
    local args = frame.args
    local interval_str = args.interval or args[1]

    -- Convert string parameters to numbers using config defaults
    local tolerance = tonumber(args.tolerance) or DEFAULT_TOLERANCE
    local min_edo = tonumber(args.min_edo) or DEFAULT_MIN_EDO
    local max_edo = tonumber(args.max_edo) or DEFAULT_MAX_EDO

    -- Validate interval parameter (required, no default)
    if not interval_str then
        return "Error: No interval specified"
    end

    -- Parse interval string to numeric ratio
    local ratio = parse_ratio(interval_str)
    if not ratio then
        return "Error: Invalid interval format (use format like '3/2')"
    end

    -- Calculate approximations (ratio is a number, not string)
    local results = calculate_edo_approximations(ratio, tolerance, min_edo, max_edo)

    if #results == 0 then
        return "No EDOs found within tolerance of " .. tolerance .. "%"
    end

    -- Build the wikitable
    -- mw-collapsible: adds [hide] toggle button
    -- sortable: makes columns sortable by clicking headers
    local output = {}
    table.insert(output, '{| class="wikitable mw-collapsible sortable"')

    -- Calculate the precise ratio in cents for the caption
    local ratio_cents = cents(ratio)
    -- Include subtitle info in caption to avoid breaking sortable functionality
    table.insert(output, string.format('|+ EDO Approximations for %s (%.2f¢)<br/><small>\'\'≤ %dedo, Relative Error ≤ %g%%\'\'</small>', interval_str, ratio_cents, max_edo, tolerance))
    table.insert(output, '|-')
    table.insert(output, '! EDO !! class="unsortable" | Step size !! Cents ([[Cent|¢]]) !! Absolute Error ([[Cent|¢]]) !! [[Relative_interval_error|Relative Error]] ([[Relative_cent|%]])')

    for _, result in ipairs(results) do
        -- Python: edo_link = f"[[{edo}edo|{edo}]]"
        local edo_link = string.format("[[%dedo|%d]]", result.edo, result.edo)

        -- Python: step_str = f"{steps}\\{edo}"
        local step_size = string.format("%d\\%d", result.steps, result.edo)

        -- Calculate approximation in cents: steps * (1200/edo)
        local approximation_cents = result.steps * (1200 / result.edo)
        local approx_str = string.format("%.2f", approximation_cents)

        -- Python: abs_err = f"{result['abs_error']:+.2f}"
        local abs_err = format_error(result.abs_error)

        -- Python: rel_err = f"{result['rel_error']:+.2f}"
        local rel_err = format_error(result.rel_error)

        table.insert(output, '|-')
        table.insert(output, string.format('| %s || %s || %s || %s || %s', edo_link, step_size, approx_str, abs_err, rel_err))
    end

    table.insert(output, '|}')

    return table.concat(output, '\n')
end

return p