Module:JI ratios

From Xenharmonic Wiki
Revision as of 07:57, 16 September 2024 by Ganaram inukshuk (talk | contribs) (Moar cleanup; refined subgroup-search code to implement bfs)
Jump to navigation Jump to search
Module documentation[view] [edit] [history] [purge]
This module may be invoked by templates using its corresponding template Template:JI ratios, or used directly from other modules.
Module:JI ratios is a draft module. It is incomplete and may not be in active development. If possible, editors are encouraged to help with its development. In the meantime, editors should avoid using this module across the Xenharmonic Wiki, except for testing.
Introspection summary for Module:JI ratios 
Functions provided (0)
Line Function Params
Lua modules required (4)
Variable Module Functions used
m Module:Mediants find_mediants_by_search_func
rat Module:Rational new
tenney_height
max_prime
cents
as_ratio
tip Module:Template input parse parse_kv_pairs
parse_numeric_pairs
utils Module:Utils _gcd

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


local rat = require("Module:Rational")
local utils = require("Module:Utils")
local tip = require("Module:Template input parse")
local m = require("Module:Mediants")
p = {}

-- TODO:
-- Adopt mediants module

-- Template for handling multiple entry of JI ratios into a template, and for
-- searching for JI ratios if automatic entry is desired.
-- This is a successor/replacement for JI ratio finder.

-- JI ratios are searched by the following params in a hierarchy:
-- - The absolute minimum for ratio search int limit, which limits the maximum
--   size of the numerator and denominator.
-- - If subgroup is present, ratios are searched by subgroup within an int
--   limit. Subgroup takes precedence over prime limit, as subgroup is
--   (typically) a subset of prime limit, so prime limit is ignored. (Nonprime
--   subgroups take precedence over prime subgroups.)
-- - If prime limit is present, ratios are searched by prime limit within an int
--   limit.
-- NOTES:
-- - Prime limits are infinite sets, so int limit is used to restrain the set
--   to a finite size. The same is true for subgroup.
-- - Tenney height is used for further filtering of ratios, and is considered
--   optional.

-- INT_LIMIT_MAX is hardcoded to limit the size of output.
-- 400 -> ~24000 ratios
-- 300 -> ~14000 ratios
-- 250 -> ~9500 ratios
-- 200 -> ~6000 ratios
-- 150 -> ~3400 ratios
-- 128 -> ~2500 ratios
-- 100 -> ~1500 ratios
local INT_LIMIT_MAX = 200
local DEFAULT_INT_LIMIT = 50

--------------------------------------------------------------------------------
----------------------- INT-LIMIT-BASED SEARCH FUNCTION ------------------------
--------------------------------------------------------------------------------

-- Find JI ratios up to an integer limit within the octave by finding mediants.
-- A cent value can be passed in to either exclude ratios that are above an
-- interval below the octave or include ratios above the octave.
function p.search_by_int_limit(integer_limit, max_cents)
	local max_cents = max_cents or 1200
	local integer_limit = integer_limit or DEFAULT_INT_LIMIT
	
	integer_limit = math.max(0, math.min(INT_LIMIT_MAX, integer_limit))
	
	local init_ratios = {{1,1}, {2,1}}
	local func = m.int_limit_search
	local args = integer_limit
	local ratios = m.find_mediants_by_search_func(init_ratios, func, args)
	
	-- If the max cents is greater than the octave, duplicate all existing
	-- ratios and raise them by the required number of octaves.
	if max_cents > 1200 then
		local new_ratios = {}
		local num_octaves_up = math.ceil(max_cents / 1200)
		
		for j = 1, num_octaves_up do
			for i = 2, #ratios do
				local num = ratios[i][1] * math.pow(2, j)
				local den = ratios[i][2]
				
				local gcd = utils._gcd(num, den)
				num = num / gcd
				den = den / gcd
				
				if math.max(num, den) <= integer_limit then
					table.insert(new_ratios, {num, den})
				end
			end
		end
		
		for i = 1, #new_ratios do
			table.insert(ratios, new_ratios[i])
		end
	end
	
	-- Remove any ratios that exceed the max cents
	
	-- Convert to ratios that Module:Rational can work with
	for i = 1, #ratios do
		ratios[i] = rat.new(ratios[i][1], ratios[i][2])
	end
	
	return ratios
end

--------------------------------------------------------------------------------
------------------------ SUBGROUP-BASED SEARCH FUNCTION ------------------------
--------------------------------------------------------------------------------

-- Subgroup-based search
-- Can support higher int limits than int-limit search can, provided the sub-
-- group is sufficiently small (about 10 members)
function p.search_by_subgroup(subgroup, int_limit, equave)
	local subgroup = subgroup or { 2, 3, 7, 11 }
	local int_limit = int_limit or 50
	local equave = equave or {2,1}
	
	local possible_values = p.find_products(subgroup, int_limit)
	local ratios = p.find_ratios_using_values(possible_values, equave)
	
	-- Convert to ratios that Module:Rational can work with
	for i = 1, #ratios do
		ratios[i] = rat.new(ratios[i][1], ratios[i][2])
	end
	
	return ratios
end

-- Helper function
-- Finds all eligible values for the numerator and denominator
function p.find_products(factors, max_product)
	local factors = factors or { 2, 3, 7, 11 }
	local max_product = max_product or 50
	
	-- Perform a breadth-first-search.
	-- Starting with the number 1 at the root node of a (simulated) search tree,
	-- explore the possible products (child nodes) of multiplying that number
	-- with exactly one each of the given factors. Any products that are less
	-- than the max product are added to the search tree, and the search
	-- recurses for each child node by finding its children produced by multi-
	-- plying by one of each factor. The search on any one branch stops if the
	-- resulting products exceed that of the max product.
	-- Products are stored as a jagged array, where the index of each inner
	-- array is the search depth. Duplicate products are excluded.
	-- NOTE: the search starts with the number 1 for this operation to work. To
	-- make sense of this, this operation can be thought of a BFS for powers
	-- pi raising factors fi (f1^p1 * f2^p2 * ... * fn^pn), so 1 is where each
	-- factor fi is raised by zero, thus BFS increases the exponents by 1.
	local products = {{1}}
	local new_products_found = true
	while new_products_found do
		local new_products = {}
		for i = 1, #factors do
			for j = 1, #products[#products] do
				local new_product = products[#products][j] * factors[i]
				if new_product <= max_product then
					local product_already_added = false
					for k = 1, #new_products do
						product_already_added = product_already_added or new_product == new_products[k]
						if product_already_added then break end
					end
					if not product_already_added then
						table.insert(new_products, new_product)
					end
				end
			end
		end
		if #new_products == 0 then
			new_products_found = false
		else
			table.insert(products, new_products)
		end
	end
	
	-- Consolidate and sort products
	local consolidated_products = {}
	for i = 1, #products do
		for j = 1, #products[i] do
			table.insert(consolidated_products, products[i][j])
		end
	end
	products = consolidated_products
	table.sort(products)
	
	return products
end

-- Finds all potential ratios whose numerator and denominator is from the list
-- of given values, and whose value, as a float, is between 1 and a given
-- equave.
function p.find_ratios_using_values(values, equave)
	local values = values or p.find_products()
	local equave = equave or { 2, 1 }
	
	local equave_as_float = equave[1]/equave[2]
	
	local ratios = {}
	for i = 1, #values do
		local denominator = values[i]
		for j = i, #values do
			local numerator = values[j]
			local gcd = utils._gcd(numerator, denominator)
			if gcd == 1 then
				local within_equave = numerator / denominator <= equave_as_float
				if within_equave then
					table.insert(ratios, {numerator, denominator})
				else
					break
				end
			end
		end
	end
	
	return ratios
end


--------------------------------------------------------------------------------
------------------------- PARAM-BASED SEARCH FUNCTIONS -------------------------
--------------------------------------------------------------------------------

-- Search for ratios based on params passed in. Each param is its own
-- function call. Params must be parsed first.
function p.search_by_params(params, max_cents)
	local max_cents = max_cents or 1200
	
	-- First get ratios up to an int limit. If no int limit was passed in, it
	-- will default to the hardcoded default value.
	local ratios = {}
	if params["Int Limit"] ~= nil then
		ratios = p.search_by_int_limit(params["Int Limit"], max_cents)
	end
	
	if params["Prime Limit"] ~= nil then
		ratios = p.filter_by_prime_limit(ratios, params["Prime Limit"])
	end
	
	if params["Tenney Height"] ~= nil then
		ratios = p.filter_by_tenney_height(ratios, params["Tenney Height"])
	end
	
	return ratios
end

-- Parse search params.
function p.parse_search_params(search_params)
	local parsed = tip.parse_kv_pairs(search_params)
	
	if parsed["Int Limit"] ~= nil then
		parsed["Int Limit"] = tonumber(parsed["Int Limit"])
	end
	
	if parsed["Tenney Height"] ~= nil then
		parsed["Tenney Height"] = tonumber(parsed["Tenney Height"])
	end
	
	if parsed["Prime Limit"] ~= nil then
		parsed["Prime Limit"] = tonumber(parsed["Prime Limit"])
	end
	
	return parsed
end

function p.search_param_footnotes(search_params)
	local result = "Not all notable ratios may be shown, and other interpretations are possible."
	
	if search_params["Prime Limit"] ~= nil then
		result = string.format("Ratios shown are within the [[%s-limit]]. %s", search_params["Prime Limit"], result)
	elseif search_params["Int Limit"] ~= nil then
		result = string.format("Ratios shown are %s-[[integer-limit|integer limit]]. %s", search_params["Int Limit"], result)
	end
	return result
end

--------------------------------------------------------------------------------
---------------------------- RATIO FILTER FUNCTIONS ----------------------------
--------------------------------------------------------------------------------

-- Filter ratios by Tenney height.
function p.filter_by_tenney_height(ratios, tenney_height)
	local tenney_height = tenney_height or 10
	local filtered_ratios = {}
	
	for i = 1, #ratios do
		local curr_tenney_height = rat.tenney_height(ratios[i])
		if curr_tenney_height <= tenney_height then
			table.insert(filtered_ratios, ratios[i])
		end
	end
	return filtered_ratios
end

-- Filter ratios by prime limit.
function p.filter_by_prime_limit(ratios, prime_limit)
	local prime_limit = prime_limit or 41
	local filtered_ratios = {}
	
	for i = 1, #ratios do
		local curr_max_prime = rat.max_prime(ratios[i])
		if curr_max_prime <= prime_limit then
			table.insert(filtered_ratios, ratios[i])
		end
	end
	return filtered_ratios
end

-- Filter ratios by (prime) subgroup. EG: 2.3.5.7
function p.filter_by_subgroup(ratios, subgroup)
	
end

-- Filter ratios by rational/nonprime subgroup. EG, 2.7/2.11/2, or 2.5.7.9
-- Does not support irrational subgroups.
function p.filter_by_nonprime_subgroup(ratios, subgroup)
	
end

--------------------------------------------------------------------------------
--------------------------- RATIO SORTING FUNCTIONS ----------------------------
--------------------------------------------------------------------------------

-- Sorts ratios by closeness to cent values.
function p.sort_by_closeness_to_cent_values(ratios, cent_values, tolerance)
	local tolerance = tolerance or 30
	
	local sorted_ratios = {}
	local curr_index = 1		-- Index of current_ratio
	for i = 1, #cent_values do
		local lower_bound = cent_values[i] - tolerance
		local upper_bound = cent_values[i] + tolerance
		local cents_within_range = true
		local curr_ratios = {}
		
		for j = curr_index, #ratios do
			local curr_ratio = ratios[j]
			local curr_cents = rat.cents(curr_ratio)
			
			if lower_bound < curr_cents and curr_cents < upper_bound then
				table.insert(curr_ratios, curr_ratio)
			--elseif curr_cents > upper_bound then
			--	curr_index = j
			--	break
			end
		end
		
		table.insert(sorted_ratios, curr_ratios)
	end
	
	return sorted_ratios
end

--------------------------------------------------------------------------------
------------------------ RATIO PARSING/INPUT FUNCTIONS -------------------------
--------------------------------------------------------------------------------

-- Parse a list of ratios from a string. String is formatted as follows:
-- "a/b; c/d; e/f; g/h"
function p.parse_ratios(unparsed)
	local parsed = tip.parse_numeric_pairs(unparsed)
	for i = 1, #parsed do
		parsed[i] = rat.new(parsed[i][1], parsed[i][2])
	end
	return parsed
end

--------------------------------------------------------------------------------
---------------------------- RATIO STRING FUNCTIONS ----------------------------
--------------------------------------------------------------------------------

-- Convert a table of ratios into a string, with options for links and delimiter
function p.ratios_as_text(ratios, add_links, delimiter)
	local add_links = add_links == true
	local delimiter = delimiter or ", "
	
	local text = ""
	if #ratios ~= 0 then
		text = add_links and string.format("[[%s]]", rat.as_ratio(ratios[1])) or rat.as_ratio(ratios[1])
		for i = 2, #ratios do
			text = text .. (add_links and string.format("%s[[%s]]", delimiter, rat.as_ratio(ratios[i])) or string.format("%s%s", delimiter, rat.as_ratio(ratios[i])))
		end
	end
	return text
end

-- Convert a table of tables into a table of text
function p.ratios_as_texts(ratios, add_links, delimiter)
	local add_links = add_links == true
	local delimiter = delimiter or ", "
	
	local texts = {}
	for i = 1, #ratios do
		local text = p.ratios_as_text(ratios[i], add_links, delimiter)
		table.insert(texts, text)
	end
	return texts
end

function p.tester()
	local params = p.parse_search_params("Int Limit: 30; Prime Limit: 17")
	--ratios = p.search_by_params(params)
	--ratios = p.sort_by_closeness_to_cent_values(ratios, {0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200}, 15)
	
	--return p.ratios_as_texts(ratios)
	
	--local ratios = p.search_by_int_limit(250)
	--return p.ratios_as_text(ratios) .. " " .. #ratios
	
	-- Using these params with the naive search algorithm (iterating through
	-- every number from to to the int limit and checking whether its factors
	-- are present in the subgroup) takes several seconds to return only 1563
	-- results using these params: factors 2, 3, 7, 11; max product: 10 million.
	local factors = { 2, 3 }
	local max_product = 5000
	
	return p.ratios_as_text(p.search_by_subgroup(factors, max_product, {3,1}))
	--return p.find_products(factors, max_product)
end

return p