Source: core/Conversion.js

import { Measure } from "../data/Measure.js";
import { Density } from "../data/Density.js";
import { CaloricContent } from "../data/CaloricContent.js";

import { Component } from "./Component.js";
import { CalculationError } from "./CalculationError.js";

import { AlcoholTable } from "../data/alcohol.table.js";
import { SyrupTable } from "../data/syrup.table.js";

/**
 * Conversions between units
 * 
 * Available conversions:
 * 
 * Alcohol:
 * 
 * |  ↓ from \ → to | DENSITY | ABV | VV  | WV  | WW  | CW  |
 * |----------------|:-------:|:---:|:---:|:---:|:---:|:---:|
 * | **DENSITY**    |  ✔      |  ✔  |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **ABV**        |  ✔      |  ✔  |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **VV**         |  ✔      |  ✔  |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **WV**         |  ✔      |  ✔  |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **WW**         |  ✔      |  ✔  |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **CW**         |  ✔      |  ✔  |  ✔  |  ✔  |  ✔  |  ✔  |
 * 
 * Syrup:
 * 
 * |  ↓ from \ → to | DENSITY | BRIX | VV  | WV  | WW  | CW  |
 * |----------------|:-------:|:----:|:---:|:---:|:---:|:---:|
 * | **DENSITY**    |  ✔      |  ✔   |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **BRIX**       |  ✔      |  ✔   |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **VV**         |  ✔      |  ✔   |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **WV**         |  ✔      |  ✔   |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **WW**         |  ✔      |  ✔   |  ✔  |  ✔  |  ✔  |  ✔  |
 * | **CW**         |  ✔      |  ✔   |  ✔  |  ✔  |  ✔  |  ✔  |
 * 
 * @hideconstructor
 */
class Conversion {
	/**
	 * @static
	 * @prop {number} correction - multiplier for complex compositions to avoid rounding errors while mixing
	 */
	static correction = 0.9999;
	/**
	 * Facade function for converting from anything to anything
	 *
	 * @static
	 * @param {IngredientType} ingredient
	 * @param {MeasureVariant} from
	 * @param {MeasureVariant} to
	 * @param {number} value
	 * @returns {number}
	 * @throws {CalculationError}
	 * 
	 * @example
	 * // returns 0.61501
	 * Conversion.convert('syrup', Measure.BRIX, Measure.WV, 50)
	 */
	static convert(ingredient, from, to, value) {
		if (from == to) return value;
		if (
			typeof Conversion.conversion_map[ingredient][from][to] != 'function'
		)
			throw new CalculationError('CONVERSION_UNAVAILABLE');
		if (typeof Conversion.validation_map[ingredient][from] == 'function') {
			if (!Conversion.validation_map[ingredient][from](value))
				throw new CalculationError('INVALID_VALUE', value);
		}
		let result = Conversion.conversion_map[ingredient][from][to](value);
		return result;
	}
	/**
	 * Facade function for validation
	 *
	 * @static
	 * @param {IngredientType} ingredient
	 * @param {MeasureVariant} measure
	 * @param {number} value
	 * @returns {number}
	 * @throws {CalculationError}
	 * 
	 * @example
	 * // returns true
	 * Conversion.validate('syrup', Measure.BRIX, 50)
	 * // returns false
	 * Conversion.validate('syrup', Measure.BRIX, 150)
	 */
	static validate(ingredient, measure, value) {
		if (typeof Conversion.validation_map[ingredient][measure] != 'object')
			throw new CalculationError('VALIDATION_UNAVAILABLE');
		let result = 
			(Conversion.validation_map[ingredient][measure].min <= value) &&
			(Conversion.validation_map[ingredient][measure].max >= value);
		return result;
	}
	static validation_map = {
		syrup: {
			[Measure.DENSITY]: { min: Density.WATER, max: Density.SUCROSE },
			[Measure.BRIX]: { min: 0, max: 100 },
			[Measure.WV]: { min: 0, max: Density.SUCROSE },
			[Measure.WW]: { min: 0, max: 1 },
			[Measure.VV]: { min: 0, max: 1 },
			[Measure.CW]: { min: 0, max: CaloricContent.SUCROSE, },
		},
		alcohol: {
			[Measure.DENSITY]: { min: Density.ETHANOL, max: Density.WATER },
			[Measure.ABV]: { min: 0, max: 100 },
			[Measure.WV]: { min: 0, max: Density.ETHANOL },
			[Measure.WW]: { min: 0, max: 1 },
			[Measure.VV]: { min: 0, max: 1 },
			[Measure.CW]: { min: 0, max: CaloricContent.ETHANOL },
		},
	};
	static conversion_map = {
		syrup: {
			[Measure.DENSITY]: {
				[Measure.WW]: (value) => SyrupTable.lookup(value, SyrupTable.COL_DENSITY, SyrupTable.COL_WW),
				[Measure.WV]: (value) => SyrupTable.lookup(value, SyrupTable.COL_DENSITY, SyrupTable.COL_WV),
				[Measure.BRIX]: (value) => SyrupTable.lookup(value, SyrupTable.COL_DENSITY, SyrupTable.COL_WW) * 100,
				[Measure.VV]: (value) => SyrupTable.lookup(value, SyrupTable.COL_DENSITY, SyrupTable.COL_VV),
				[Measure.CW]: (value) => SyrupTable.lookup(value, SyrupTable.COL_DENSITY, SyrupTable.COL_WW) * CaloricContent.SUCROSE,
			},
			[Measure.WW]: {
				[Measure.DENSITY]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WW, SyrupTable.COL_DENSITY),
				[Measure.WV]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WW, SyrupTable.COL_WV),
				[Measure.BRIX]: (value) => value * 100,
				[Measure.VV]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WW, SyrupTable.COL_VV),
				[Measure.CW]: (value) => value * CaloricContent.SUCROSE,
			},
			[Measure.BRIX]: {
				[Measure.DENSITY]: (value) => SyrupTable.lookup(value * 0.01, SyrupTable.COL_WW, SyrupTable.COL_DENSITY),
				[Measure.WV]: (value) => SyrupTable.lookup(value * 0.01, SyrupTable.COL_WW, SyrupTable.COL_WV),
				[Measure.WW]: (value) => value * 0.01,
				[Measure.VV]: (value) => SyrupTable.lookup(value * 0.01, SyrupTable.COL_WW, SyrupTable.COL_VV),
				[Measure.CW]: (value) => value * 0.01 * CaloricContent.SUCROSE,
			},
			[Measure.WV]: {
				[Measure.DENSITY]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WV, SyrupTable.COL_DENSITY),
				[Measure.WW]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WV, SyrupTable.COL_WW),
				[Measure.BRIX]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WV, SyrupTable.COL_WW) * 100,
				[Measure.VV]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WV, SyrupTable.COL_VV),
				[Measure.CW]: (value) => SyrupTable.lookup(value, SyrupTable.COL_WV, SyrupTable.COL_WW) * CaloricContent.SUCROSE,
			},
			[Measure.VV]: {
				[Measure.DENSITY]: (value) => SyrupTable.lookup(value, SyrupTable.COL_VV, SyrupTable.COL_DENSITY),
				[Measure.WW]: (value) => SyrupTable.lookup(value, SyrupTable.COL_VV, SyrupTable.COL_WW),
				[Measure.BRIX]: (value) => SyrupTable.lookup(value, SyrupTable.COL_VV, SyrupTable.COL_WW) * 100,
				[Measure.WV]: (value) => SyrupTable.lookup(value, SyrupTable.COL_VV, SyrupTable.COL_WV),
				[Measure.CW]: (value) => SyrupTable.lookup(value, SyrupTable.COL_VV, SyrupTable.COL_WW) * CaloricContent.SUCROSE,
			},
			[Measure.CW]: {
				[Measure.WW]: (value) => value / CaloricContent.SUCROSE,
				[Measure.DENSITY]: (value) => SyrupTable.lookup(Conversion.convert('syrup', Measure.CW, Measure.WW, value), SyrupTable.COL_WW, SyrupTable.COL_DENSITY),
				[Measure.BRIX]: (value) => Conversion.convert('syrup', Measure.CW, Measure.WW, value) * 100,
				[Measure.WV]: (value) => SyrupTable.lookup(Conversion.convert('syrup', Measure.CW, Measure.WW, value), SyrupTable.COL_WW, SyrupTable.COL_WV),
				[Measure.VV]: (value) => SyrupTable.lookup(Conversion.convert('syrup', Measure.CW, Measure.WW, value), SyrupTable.COL_WW, SyrupTable.COL_VV),
			},
		},
		alcohol: {
			[Measure.DENSITY]: {
				[Measure.ABV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_DENSITY, AlcoholTable.COL_VV, true) * 100,
				[Measure.VV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_DENSITY, AlcoholTable.COL_VV, true),
				[Measure.WW]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_DENSITY, AlcoholTable.COL_WW, true),
				[Measure.WV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_DENSITY, AlcoholTable.COL_WV, true),
				[Measure.CW]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_DENSITY, AlcoholTable.COL_WW, true) * CaloricContent.ETHANOL,
			},
			[Measure.VV]: {
				[Measure.ABV]: (value) => value * 100,
				[Measure.DENSITY]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_VV, AlcoholTable.COL_DENSITY),
				[Measure.WW]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_VV, AlcoholTable.COL_WW),
				[Measure.WV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_VV, AlcoholTable.COL_WV),
				[Measure.CW]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_VV, AlcoholTable.COL_WW) * CaloricContent.ETHANOL,
			},
			[Measure.ABV]: {
				[Measure.DENSITY]: (value) => AlcoholTable.lookup(value * 0.01, AlcoholTable.COL_VV, AlcoholTable.COL_DENSITY),
				[Measure.WW]: (value) => AlcoholTable.lookup(value * 0.01, AlcoholTable.COL_VV, AlcoholTable.COL_WW, ),
				[Measure.WV]: (value) => AlcoholTable.lookup(value * 0.01, AlcoholTable.COL_VV, AlcoholTable.COL_WV, ),
				[Measure.VV]: (value) => value * 0.01,
				[Measure.CW]: (value) => AlcoholTable.lookup(value * 0.01, AlcoholTable.COL_VV, AlcoholTable.COL_WW, ) * CaloricContent.ETHANOL,
			},
			[Measure.WW]: {
				[Measure.ABV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WW, AlcoholTable.COL_VV, ) * 100,
				[Measure.DENSITY]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WW, AlcoholTable.COL_DENSITY),
				[Measure.VV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WW, AlcoholTable.COL_VV, ),
				[Measure.WV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WW, AlcoholTable.COL_WV, ),
				[Measure.CW]: (value) => value * CaloricContent.ETHANOL,
			},
			[Measure.WV]: {
				[Measure.ABV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WV, AlcoholTable.COL_VV, ) * 100,
				[Measure.DENSITY]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WV, AlcoholTable.COL_DENSITY, ),
				[Measure.WW]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WV, AlcoholTable.COL_WW, ),
				[Measure.VV]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WV, AlcoholTable.COL_VV, ),
				[Measure.CW]: (value) => AlcoholTable.lookup(value, AlcoholTable.COL_WV, AlcoholTable.COL_WW, ) * CaloricContent.ETHANOL,
			},
			[Measure.CW]: {
				[Measure.WW]: (value) => value / CaloricContent.ETHANOL,
				[Measure.DENSITY]: (value) => AlcoholTable.lookup(Conversion.convert('alcohol', Measure.CW, Measure.WW, value), AlcoholTable.COL_WW, AlcoholTable.COL_DENSITY),
				[Measure.ABV]: (value) => Conversion.convert('alcohol', Measure.CW, Measure.VV, value) * 100,
				[Measure.WV]: (value) => AlcoholTable.lookup(Conversion.convert('alcohol', Measure.CW, Measure.WW, value), AlcoholTable.COL_WW, AlcoholTable.COL_WV),
				[Measure.VV]: (value) => AlcoholTable.lookup(Conversion.convert('alcohol', Measure.CW, Measure.WW, value), AlcoholTable.COL_WW, AlcoholTable.COL_VV),
			},
		},
	};
	/**
	 * Binary search method for really complex things
	 *
	 * @static
	 * @param {Function} callback - callback function, must take 1 number argument
	 * @param {number} target - value to get from closure
	 * @param {number} min - min value of argument to search
	 * @param {number} max - max value of argument
	 * @param {number} [precision=0.00001] - precision to target
	 * @param {boolean} [inverse=false] - inverse search (if result is bigger than target, the argument gets smaller)
	 * 
	 * @returns {number}
	 * @throws {CalculationError}
	 */
	static binary_search(callback, target, min, max, precision, inverse) {
		precision = precision || 0.00001;
		let result, now, diff;
		let m = inverse ? -1 : 1;
		let gmin = min,
			gmax = max;
		let iteration_limit = 100;
		do {
			result = (min + max) * 0.5;
			now = callback(result);
			diff = Math.abs(now - target);
			if (diff < precision) break;
			if (
				Math.abs(result - gmin) < precision ||
				Math.abs(result - gmax) < precision
			) {
				console.error(' -- binary search failed at: ', {
					now,
					target,
					result,
					diff,
					precision,
				});
				throw new CalculationError('ERROR_BINARY_SEARCH_OUT_OF_BOUNDS');
			}
			if (m * now > m * target) {
				max = result;
			} else {
				min = result;
			}
			iteration_limit--;
			if (iteration_limit <= 0) {
				console.error(' -- binary search failed at: ', {
					now,
					target,
					result,
					diff,
					precision,
				});
				throw new CalculationError('ERROR_BINARY_SEARCH_OUT_OF_BOUNDS');
			}
		} while (diff > precision);
		return Math.round(result / precision) * precision;
	}
}

export { Conversion };