Module:JI ratios

From Xenharmonic Wiki
Jump to navigation Jump to search
Test Template Info-Icon - Version (2).svg Module documentation[view] [edit] [history] [purge]
Icon-Todo.png Todo: Documentation

local rat = require("Module:Rational")
local utils = require("Module:Utils")
local tip = require("Module:Template input parse")
local med = require("Module:Mediants")
local yesno = require("Module:Yesno")
local getArgs = require("Module:Arguments").getArgs
p = {}

-- TODO
-- - Move filtering functions to separate 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.

-- Module searches for ratios that are, at the minimum, up to an equave and are
-- up to some integer limit. Search hierarchy is as follows:
-- - Search by subgroup (includes non-integer and rational elements)
-- - Then search by prime limit
-- - Then search by odd limit (to be implemented)
-- - Then search by int limit

-- Optional args omit ratios that don't meet certain conditions, and are used
-- to further limit the number of ratios found. Current options include:
-- - Tenney Height: omits ratios that exceed some max Tenney height. Has no
--   effect if no Tenney height is passed in.
-- - Complements Only: omits ratios and their equave complements if either would
--   be omitted by Tenney height, or if no Tenney height is entered, omits
--   ratios whose complements are missing.

local DEFAULT_EQUAVE = rat.new(2)
local DEFAULT_INT_LIMIT = 30

--------------------------------------------------------------------------------
------------------------------- FILTER FUNCTIONS -------------------------------
--------------------------------------------------------------------------------

-- Filter function removes certain ratios that don't meet some requirement.
-- Filters currently include:
-- - Removing ratios that exceed a max Tenney height.
-- - Removing ratios whose complement would exceed a max Tenney height or int limit
function p.filter_ratios(ratios, equave, int_limit, tenney_height, complements_only)
	--local tenney_height    = tenney_height or 1/0			-- Default Tenney height is +infinity
	--local complements_only = complements_only or false		-- Default is to allow ratios, regardless of whether their complement would be excluded
	
	local filtered_ratios = {}
	for i = 1, #ratios do
		local complement = rat.mul(rat.inv(ratios[i]), equave)
		local ratio_th   = rat.tenney_height(ratios[i])
		local compl_th   = rat.tenney_height(complement)
		
		-- Are the ratios within the Tenney height?
		-- Has no effect (defaults to TRUE) if Tenney height is infinity.
		local ratio_within_th = ratio_th <= tenney_height
		local compl_within_th = compl_th <= tenney_height
		
		-- Is the ratio's complement within the int limit?
		local compl_within_int_limit = rat.is_within_int_limit(complement, int_limit)
		
		if complements_only then
			if ratio_within_th and compl_within_th and compl_within_int_limit then
				table.insert(filtered_ratios, ratios[i])
			end
		else
			if ratio_within_th then
				table.insert(filtered_ratios, ratios[i])
			end
		end
	end
	
	return filtered_ratios
end

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

-- Int limit search finds ratios from 1/1 to an equave, where each ratio's
-- numerator or denominator don't exceed the int limit.
function p.search_by_int_limit(equave, int_limit)
	--local equave    = equave or rat.new(2,1)	-- Defualt equave is 2/1.
	--local int_limit = int_limit or 50			-- Default is 50
 
	-- Find all ratios from 1/1 to the equave by finding mediants between 1/1
	-- and 1/0. Mediants module has a function for this built-in, but includes
	-- ratios that exceed the equave (for example, if the equave is 2/1, then
	-- the mediants function will include 3/1 and 4/1), which must be removed
	-- afterwards.
	local init_ratios = {{1,1}, {1,0}}
	local ratios = med.find_only_mediants_by_int_limit(init_ratios, int_limit)
	
	-- 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
	
	-- Remove ratios that exceed the equave.
	-- Because the ratios are already sorted by cent value, remove ratios from
	-- the end of the table until there are no more ratios to remove.
	while rat.gt(ratios[#ratios], equave) do
		table.remove(ratios, #ratios)
	end
	
	return ratios
end

--------------------------------------------------------------------------------
-------------------------- ODD-LIMIT SEARCH FUNCTION ---------------------------
--------------------------------------------------------------------------------

-- to be implemented

--------------------------------------------------------------------------------
------------------------- PRIME-LIMIT SEARCH FUNCTION --------------------------
--------------------------------------------------------------------------------

-- Prime limit search finds ratios with prime factors that don't exceed some
-- prime limit.
-- Upper bounds for searching is the equave and int limit.
function p.search_by_prime_limit(equave, int_limit, prime_limit)
	--local equave      = equave or rat.new(2,1)	-- Defualt equave is 2/1.
	--local int_limit   = int_limit or 50			-- Default is 50
	--local prime_limit = prime_limit or 5		-- Default is 5-prime-limit

	-- Convert prime limit into an equivalent subgroup (EG, 7-limit becomes
	-- 2.3.5.7) so that it can be passed into the subgroup search function.
	local primes = {}
	for i = 2, prime_limit do
		local is_prime = true
		for j = 2, math.floor(math.sqrt(i)) do
			if i % j == 0 then
				is_prime = false
				break
			end
		end
		if is_prime then
			table.insert(primes, rat.new(i))
		end
	end
	
	-- Perform subgroup search.
	return p.search_by_subgroup(equave, int_limit, primes)
end

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

-- Subgroup search find ratios that are products of at least two (not
-- necessarily unique) elements from the subgroup. EG, for 2.3.7, 2/1, 3/2, 7/4,
-- and 7/6 are within the subgroup, but not 5/4 because there's no 5.
-- Upper bounds for searching is the equave and int limit.
function p.search_by_subgroup(equave, int_limit, subgroup)
	--local equave    = equave or rat.new(2,1)	-- Defualt equave is 2/1.
	--local int_limit = int_limit or 50			-- Default is 50
	--local subgroup  = subgroup or {rat.new(2), rat.new(3), rat.new(7)}		-- Default is 2.3.7 subgroup
	
	-- Find all possible ways to multiply subgroup elements with one another
	-- using breadth-first-search. Products found this way should not exceed the
	-- int limit, and if a subgroup element is rational, neither its numerator
	-- nor denominator should exceed the int limit.
	local products = { rat.new(1) }
	local i = 1
	while i <= #products do
		-- Multiply each subgroup element by the current ratio. The table of
		-- product ratios created this way is merged with the running table of
		-- ratios. This is the Cartesian product of the single ratio as a set,
		-- with the subgroup elements as a set, or {p/q} X subgroup.
		local new_products = {}
		for j = 1, #subgroup do
			local new_ratio = rat.mul(products[i], subgroup[j])
			if rat.is_within_int_limit(new_ratio, int_limit) and not p.find_ratio_in_table(new_products, new_ratio) then
				table.insert(new_products, new_ratio)
			end
		end
		
		-- Merge new products with the table of products, omitting duplicates.
		p.merge_tables(products, new_products)
		i = i + 1
	end
	
	-- Sort for next step
	table.sort(products, rat.lt)
	
	-- Use the products found to find all ratios between 1 and the equave.
	-- For each ratio in the table of products, create a set of new ratios by
	-- having that ratio be the numerator and all successive ratios be possible
	-- denominators. Store these new ratios in a table, and repeat with all
	-- successive products, omitting duplicats. From earlier testing, this is
	-- faster than performing BFS on each ratio, and yields the same results.
	local ratios = {}
	for i = 1, #products do
		local new_ratios = {}
		for j = i, #products do
			local new_ratio = rat.div(products[j], products[i])	
			if rat.as_float(new_ratio) > rat.as_float(equave) then break end
			
			if not p.find_ratio_in_table(new_ratios, new_ratio) and rat.is_within_int_limit(new_ratio, int_limit) then
				table.insert(new_ratios, new_ratio)
			end
		end
		
		-- Merge new ratios with the table of ratios, omitting duplicates.
		p.merge_tables(ratios, new_ratios)
	end
	
	-- Sort
	table.sort(ratios, rat.lt)
	
	return ratios
end

-- Heleper function; merges elements from source table with destination table
-- while disallowing duplicates.
function p.merge_tables(dest_table, source_table)
	for i = 1, #source_table do
		if not p.find_ratio_in_table(dest_table, source_table[i]) then
			table.insert(dest_table, source_table[i])
		end
	end
end

-- Helper function for merge function.
function p.find_ratio_in_table(table_, ratio)
	local found = false
	for i = 1, #table_ do
		if rat.as_float(table_[i]) == rat.as_float(ratio) then
			found = true
			break
		end
	end
	return found
end

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

-- Convert a table of ratios into a string, with options for links and delimiter
function p.ratios_as_string(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 jagged array of ratios into an array of strings
function p.ratios_as_strings(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_string(ratios[i], add_links, delimiter)
		table.insert(texts, text)
	end
	return texts
end

--------------------------------------------------------------------------------
---------------------------- ARG-PARSING FUNCTION ------------------------------
--------------------------------------------------------------------------------

-- Parse search args if entered as one string. Use is to be determined.
function p.parse_args(search_args)
	local parsed = tip.parse_kv_pairs(search_args)
	
	if parsed["Equave"] ~= nil then
		parsed["Equave"] = rat.parse(parsed["Equave"])
	end
	
	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
	
	if parsed["Subgroup"] ~= nil then
		local subgroup_elements = tip.parse_numeric_pairs(parsed["Subgroup"], ".", "/", true)
		for i = 1, #subgroup_elements do
			subgroup_elements[i] = rat.new(subgroup_elements[i][1], subgroup_elements[i][2])
		end
		parsed["Subgroup"] = subgroup_elements
	end
	
	if parsed["Complements Only"] ~= nil then
		parsed["Complements Only"] = yesno(parsed["Complements Only"])
	end
	
	return parsed
end

--------------------------------------------------------------------------------
----------------------------- INVOKABLE FUNCTIONS ------------------------------
--------------------------------------------------------------------------------

-- Function callable by other modules
-- Ratios are returned as a table, for use with other modules.
function p._ji_ratios(args)
	-- Args for ease of access
	equave      = args["Equave"]		or DEFAULT_EQUAVE
	int_limit   = args["Int Limit"]		or DEFAULT_INT_LIMIT
	odd_limit   = args["Odd Limit"]
	prime_limit = args["Prime Limit"]
	subgroup    = args["Subgroup"]
	
	-- Filtering args
	tenney_height    = args["Tenney Height"]    or 1/0		-- Default Tenney height is infinity
	complements_only = args["Complements Only"] or false	-- Default is to include all ratios
	
	local ratios = {}
	if subgroup ~= nil then
		ratios = p.search_by_subgroup(equave, int_limit, subgroup)
	elseif prime_limit ~= nil then
		ratios = p.search_by_prime_limit(equave, int_limit, prime_limit)
	elseif int_limit ~= nil then
		ratios = p.search_by_int_limit(equave, int_limit)
	end
	
	-- Filter ratios
	ratios = p.filter_ratios(ratios, equave, int_limit, tenney_height, complements_only)
	
	return ratios
end

-- Invokable function; for templates
-- Ratios are returned as a comma-delimited list, for use with being displayed
-- as a list.
function p.ji_ratios(frame)
	args = getArgs(frame)
	
	-- Preprocess equave
	-- Ratios are searched from 1/1 to some equave (default 2/1), so an equave
	-- must be passed in.
	args["Equave"] = args["Equave"] ~= nil and rat.parse(args["Equave"])
	
	-- Preprocess int limit
	-- Ratios are searched up to some int limit (default 50), so an int limit
	-- must be passed in.
	args["Int Limit"] = args["Int Limit"] ~= nil and tonumber(args["Int Limit"])

	-- Preprocess Tenney height
	if args["Tenney Height"] ~= nil then
		args["Tenney Height"] = tonumber(args["Tenney Height"])
	end
	
	-- Preprocess prime limit
	if args["Prime Limit"] ~= nil then
		args["Prime Limit"] = tonumber(args["Prime Limit"])
	end
	
	-- Preprocess subgroup
	if args["Subgroup"] ~= nil then
		local subgroup_elements = tip.parse_numeric_pairs(args["Subgroup"], ".", "/", true)
		for i = 1, #subgroup_elements do
			subgroup_elements[i] = rat.new(subgroup_elements[i][1], subgroup_elements[i][2])
		end
		args["Subgroup"] = subgroup_elements
	end
	
	if args["Complements Only"] ~= nil then
		args["Complements Only"] = yesno(args["Complements Only"], false)
	end
	
	-- Find and return ratios
	ratios = p._ji_ratios(args)
	return p.ratios_as_string(ratios)
end

function p.tester()
	return p.ratios_as_string(p._ji_ratios(p.parse_args("Int Limit: 16; Equave: 3/1; Complements Only: 0")))
end

--------------------------------------------------------------------------------
---------------------------- FUNCTIONS TO BE MOVED -----------------------------
--------------------------------------------------------------------------------

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

-- Sorts ratios by closeness to cent values. Move to new module?
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

return p