From d3d716f1bced006159d95905e61559fe0e41f92d Mon Sep 17 00:00:00 2001 From: Kirk Swenson Date: Tue, 22 Oct 2024 17:39:18 -0700 Subject: [PATCH] feat: implement logical functions and greatCircleDistance function --- .../formula/functions/logic-functions.test.ts | 177 ++++++++++++++++++ .../formula/functions/logic-functions.ts | 75 ++++++++ v3/src/models/formula/functions/math.ts | 25 +-- .../formula/functions/other-functions.test.ts | 24 +++ .../formula/functions/other-functions.ts | 42 ++++- .../formula/functions/string-functions.ts | 2 +- v3/src/utilities/math-utils.ts | 4 +- 7 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 v3/src/models/formula/functions/logic-functions.test.ts create mode 100644 v3/src/models/formula/functions/logic-functions.ts diff --git a/v3/src/models/formula/functions/logic-functions.test.ts b/v3/src/models/formula/functions/logic-functions.test.ts new file mode 100644 index 0000000000..3870c77a39 --- /dev/null +++ b/v3/src/models/formula/functions/logic-functions.test.ts @@ -0,0 +1,177 @@ +import { UNDEF_RESULT } from "./function-utils" +import { math } from "./math" + +describe("boolean", () => { + it("works as expected", () => { + const fn = math.compile("boolean(x)") + expect(fn.evaluate({ x: undefined })).toBe(UNDEF_RESULT) + expect(fn.evaluate({ x: "" })).toBe(UNDEF_RESULT) + expect(fn.evaluate({ x: true })).toBe(true) + expect(fn.evaluate({ x: false })).toBe(false) + expect(fn.evaluate({ x: "true" })).toBe(true) + expect(fn.evaluate({ x: "false" })).toBe(false) + expect(fn.evaluate({ x: "TRUE" })).toBe(true) + expect(fn.evaluate({ x: "FALSE" })).toBe(false) + expect(fn.evaluate({ x: 1 })).toBe(true) + expect(fn.evaluate({ x: 0 })).toBe(false) + expect(fn.evaluate({ x: -1 })).toBe(true) + expect(fn.evaluate({ x: NaN })).toBe(true) + expect(fn.evaluate({ x: "0" })).toBe(false) + // NaN !== 0 + expect(fn.evaluate({ x: "foo" })).toBe(true) + expect(fn.evaluate({ x: "bar" })).toBe(true) + }) +}) + +describe("isBoolean", () => { + it("works as expected", () => { + const fn = math.compile("isBoolean(x)") + expect(fn.evaluate({ x: undefined })).toBe(false) + expect(fn.evaluate({ x: "" })).toBe(false) + expect(fn.evaluate({ x: true })).toBe(true) + expect(fn.evaluate({ x: false })).toBe(true) + expect(fn.evaluate({ x: "true" })).toBe(true) + expect(fn.evaluate({ x: "false" })).toBe(true) + expect(fn.evaluate({ x: "TRUE" })).toBe(true) + expect(fn.evaluate({ x: "FALSE" })).toBe(true) + expect(fn.evaluate({ x: 1 })).toBe(false) + expect(fn.evaluate({ x: 0 })).toBe(false) + expect(fn.evaluate({ x: -1 })).toBe(false) + expect(fn.evaluate({ x: NaN })).toBe(false) + expect(fn.evaluate({ x: "0" })).toBe(false) + expect(fn.evaluate({ x: "foo" })).toBe(false) + expect(fn.evaluate({ x: "bar" })).toBe(false) + }) +}) + +describe("isBoundary", () => { + it("works as expected", () => { + const fn = math.compile("isBoundary(x)") + expect(fn.evaluate({ x: undefined })).toBe(false) + expect(fn.evaluate({ x: "" })).toBe(false) + expect(fn.evaluate({ x: true })).toBe(false) + expect(fn.evaluate({ x: false })).toBe(false) + expect(fn.evaluate({ x: "true" })).toBe(false) + expect(fn.evaluate({ x: "false" })).toBe(false) + expect(fn.evaluate({ x: "TRUE" })).toBe(false) + expect(fn.evaluate({ x: "FALSE" })).toBe(false) + expect(fn.evaluate({ x: 1 })).toBe(false) + expect(fn.evaluate({ x: 0 })).toBe(false) + expect(fn.evaluate({ x: -1 })).toBe(false) + expect(fn.evaluate({ x: NaN })).toBe(false) + expect(fn.evaluate({ x: "0" })).toBe(false) + expect(fn.evaluate({ x: "foo" })).toBe(false) + expect(fn.evaluate({ x: "bar" })).toBe(false) + expect(fn.evaluate({ x: { jsonBoundaryObject: {} } })).toBe(true) + }) +}) + +describe("isColor", () => { + it("works as expected", () => { + const fn = math.compile("isColor(x)") + expect(fn.evaluate({ x: undefined })).toBe(false) + expect(fn.evaluate({ x: "" })).toBe(false) + expect(fn.evaluate({ x: true })).toBe(false) + expect(fn.evaluate({ x: false })).toBe(false) + expect(fn.evaluate({ x: "true" })).toBe(false) + expect(fn.evaluate({ x: "false" })).toBe(false) + expect(fn.evaluate({ x: "TRUE" })).toBe(false) + expect(fn.evaluate({ x: "FALSE" })).toBe(false) + expect(fn.evaluate({ x: 1 })).toBe(false) + expect(fn.evaluate({ x: 0 })).toBe(false) + expect(fn.evaluate({ x: -1 })).toBe(false) + expect(fn.evaluate({ x: NaN })).toBe(false) + expect(fn.evaluate({ x: "0" })).toBe(false) + expect(fn.evaluate({ x: "foo" })).toBe(false) + expect(fn.evaluate({ x: "bar" })).toBe(false) + expect(fn.evaluate({ x: "#aabbcc" })).toBe(true) + expect(fn.evaluate({ x: "#aabbcc88" })).toBe(true) + expect(fn.evaluate({ x: "rgb(1, 2, 3)" })).toBe(true) + }) +}) + +describe("isDate", () => { + it("works as expected", () => { + const fn = math.compile("isDate(x)") + expect(fn.evaluate({ x: undefined })).toBe(false) + expect(fn.evaluate({ x: "" })).toBe(false) + expect(fn.evaluate({ x: true })).toBe(false) + expect(fn.evaluate({ x: false })).toBe(false) + expect(fn.evaluate({ x: "true" })).toBe(false) + expect(fn.evaluate({ x: "false" })).toBe(false) + expect(fn.evaluate({ x: "TRUE" })).toBe(false) + expect(fn.evaluate({ x: "FALSE" })).toBe(false) + expect(fn.evaluate({ x: 1 })).toBe(false) + expect(fn.evaluate({ x: 0 })).toBe(false) + expect(fn.evaluate({ x: -1 })).toBe(false) + expect(fn.evaluate({ x: NaN })).toBe(false) + expect(fn.evaluate({ x: "0" })).toBe(false) + expect(fn.evaluate({ x: "foo" })).toBe(false) + expect(fn.evaluate({ x: "bar" })).toBe(false) + expect(fn.evaluate({ x: new Date() })).toBe(true) + expect(fn.evaluate({ x: "2024-10-22" })).toBe(true) + }) +}) + +describe("isFinite", () => { + it("works as expected", () => { + const fn = math.compile("isFinite(x)") + expect(fn.evaluate({ x: undefined })).toBe(false) + expect(fn.evaluate({ x: "" })).toBe(false) + expect(fn.evaluate({ x: true })).toBe(true) + expect(fn.evaluate({ x: false })).toBe(true) + expect(fn.evaluate({ x: "true" })).toBe(false) + expect(fn.evaluate({ x: "false" })).toBe(false) + expect(fn.evaluate({ x: "TRUE" })).toBe(false) + expect(fn.evaluate({ x: "FALSE" })).toBe(false) + expect(fn.evaluate({ x: 1 })).toBe(true) + expect(fn.evaluate({ x: 0 })).toBe(true) + expect(fn.evaluate({ x: -1 })).toBe(true) + expect(fn.evaluate({ x: NaN })).toBe(false) + expect(fn.evaluate({ x: "0" })).toBe(true) + expect(fn.evaluate({ x: "foo" })).toBe(false) + expect(fn.evaluate({ x: "bar" })).toBe(false) + }) +}) + +describe("isMissing", () => { + it("works as expected", () => { + const fn = math.compile("isMissing(x)") + expect(fn.evaluate({ x: undefined })).toBe(true) + expect(fn.evaluate({ x: "" })).toBe(true) + expect(fn.evaluate({ x: true })).toBe(false) + expect(fn.evaluate({ x: false })).toBe(false) + expect(fn.evaluate({ x: "true" })).toBe(false) + expect(fn.evaluate({ x: "false" })).toBe(false) + expect(fn.evaluate({ x: "TRUE" })).toBe(false) + expect(fn.evaluate({ x: "FALSE" })).toBe(false) + expect(fn.evaluate({ x: 1 })).toBe(false) + expect(fn.evaluate({ x: 0 })).toBe(false) + expect(fn.evaluate({ x: -1 })).toBe(false) + expect(fn.evaluate({ x: NaN })).toBe(false) + expect(fn.evaluate({ x: "0" })).toBe(false) + expect(fn.evaluate({ x: "foo" })).toBe(false) + expect(fn.evaluate({ x: "bar" })).toBe(false) + }) +}) + +describe("isNumber", () => { + it("works as expected", () => { + const fn = math.compile("isNumber(x)") + expect(fn.evaluate({ x: undefined })).toBe(false) + expect(fn.evaluate({ x: "" })).toBe(false) + expect(fn.evaluate({ x: true })).toBe(true) + expect(fn.evaluate({ x: false })).toBe(true) + expect(fn.evaluate({ x: "true" })).toBe(false) + expect(fn.evaluate({ x: "false" })).toBe(false) + expect(fn.evaluate({ x: "TRUE" })).toBe(false) + expect(fn.evaluate({ x: "FALSE" })).toBe(false) + expect(fn.evaluate({ x: 1 })).toBe(true) + expect(fn.evaluate({ x: 0 })).toBe(true) + expect(fn.evaluate({ x: -1 })).toBe(true) + expect(fn.evaluate({ x: NaN })).toBe(false) + expect(fn.evaluate({ x: "0" })).toBe(true) + expect(fn.evaluate({ x: "foo" })).toBe(false) + expect(fn.evaluate({ x: "bar" })).toBe(false) + }) +}) diff --git a/v3/src/models/formula/functions/logic-functions.ts b/v3/src/models/formula/functions/logic-functions.ts new file mode 100644 index 0000000000..406c27debd --- /dev/null +++ b/v3/src/models/formula/functions/logic-functions.ts @@ -0,0 +1,75 @@ +import { colord, Colord } from "colord" +import { isDateString } from "../../../utilities/date-parser" +import { isDate } from "../../../utilities/date-utils" +import { hasOwnProperty } from "../../../utilities/js-utils" +import { isValueEmpty, isValueNonEmpty } from "../../../utilities/math-utils" +import { FValue, IFormulaMathjsFunction } from "../formula-types" + +export const logicFunctions: Record = { + + boolean: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + if (isValueEmpty(arg)) return "" + if (arg === true || arg === false) return arg + if (typeof arg === "string" && arg.toLowerCase() === "false") return false + if (typeof arg === "string" && arg.toLowerCase() === "true") return true + return Number(arg) !== 0 + } + }, + + isBoolean: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + if (typeof arg === "string") { + arg = arg.toLowerCase() + } + const boolValues: FValue[] = [true, false, "true", "false"] + return boolValues.indexOf(arg) >= 0 + } + }, + + isBoundary: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + return typeof arg === "object" && hasOwnProperty(arg, "jsonBoundaryObject") + } + }, + + isColor: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + return (arg instanceof Colord || typeof arg === "string") + ? colord(arg).isValid() + : false + } + }, + + isDate: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + return isDate(arg) || isDateString(arg) + } + }, + + isFinite: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + return isValueNonEmpty(arg) && isFinite(Number(arg)) + } + }, + + isMissing: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + return isValueEmpty(arg) + } + }, + + isNumber: { + numOfRequiredArguments: 1, + evaluate: (arg: FValue) => { + return isValueNonEmpty(arg) && !isNaN(Number(arg)) + } + } +} diff --git a/v3/src/models/formula/functions/math.ts b/v3/src/models/formula/functions/math.ts index d3917491c6..7d8d7b0949 100644 --- a/v3/src/models/formula/functions/math.ts +++ b/v3/src/models/formula/functions/math.ts @@ -1,17 +1,18 @@ -import { create, all, MathNode } from 'mathjs' +import { create, all, MathNode } from "mathjs" import { CODAPMathjsFunctionRegistry, CurrentScope, EvaluateFunc, EvaluateFuncWithAggregateContextSupport, EvaluateRawFunc, FValue, FValueOrArray -} from '../formula-types' -import { evaluateNode, getRootScope } from './function-utils' -import { arithmeticFunctions } from './arithmetic-functions' -import { dateFunctions } from './date-functions' -import { stringFunctions } from './string-functions' -import { lookupFunctions } from './lookup-functions' -import { otherFunctions } from './other-functions' -import { aggregateFunctions } from './aggregate-functions' -import { semiAggregateFunctions } from './semi-aggregate-functions' -import { operators } from './operators' +} from "../formula-types" +import { aggregateFunctions } from "./aggregate-functions" +import { arithmeticFunctions } from "./arithmetic-functions" +import { dateFunctions } from "./date-functions" +import { evaluateNode, getRootScope } from "./function-utils" +import { logicFunctions } from "./logic-functions" +import { lookupFunctions } from "./lookup-functions" +import { operators } from "./operators" +import { otherFunctions } from "./other-functions" +import { semiAggregateFunctions } from "./semi-aggregate-functions" +import { stringFunctions } from "./string-functions" export const math = create(all) @@ -69,6 +70,8 @@ export const fnRegistry = { ...arithmeticFunctions, + ...logicFunctions, + ...dateFunctions, ...stringFunctions, diff --git a/v3/src/models/formula/functions/other-functions.test.ts b/v3/src/models/formula/functions/other-functions.test.ts index fcb9d6f115..276465d722 100644 --- a/v3/src/models/formula/functions/other-functions.test.ts +++ b/v3/src/models/formula/functions/other-functions.test.ts @@ -77,3 +77,27 @@ describe("number", () => { expect(fn.evaluate()).toEqual(UNDEF_RESULT) }) }) + +describe("greatCircleDistance", () => { + it("returns empty if not enough numeric arguments are provided", () => { + expect(math.compile("greatCircleDistance()").evaluate()).toBe(UNDEF_RESULT) + expect(math.compile("greatCircleDistance(0)").evaluate()).toBe(UNDEF_RESULT) + expect(math.compile("greatCircleDistance(0, 0)").evaluate()).toBe(UNDEF_RESULT) + expect(math.compile("greatCircleDistance(0, 0, 0)").evaluate()).toBe(UNDEF_RESULT) + expect(math.compile("greatCircleDistance(0, 0, 0, 'a')").evaluate()).toBe(UNDEF_RESULT) + expect(math.compile("greatCircleDistance('a', 'b', 'c', 'd')").evaluate()).toBe(UNDEF_RESULT) + }) + + it("returns valid values for legitimate arguments", () => { + const fn = math.compile("greatCircleDistance(lat1, long1, lat2, long2)") + // distance from New York to San Francisco (CODAP example) + // note: the CODAP example uses positive longitudes where negative longitudes would be expected + // This doesn't affect the result, but could be confusing. + expect(fn.evaluate({ lat1: 40.66, long1: 74, lat2: 37.8, long2: 122.4 })).toBeCloseTo(4128, -1) + // distance from New York to London (ChatGPT) + expect(fn.evaluate({ lat1: 40.7128, long1: -74.0060, lat2: 51.5074, long2: -0.1278 })).toBeCloseTo(5571, -1) + // distance from Tokyo to Sydney (ChatGPT) + // TODO: CODAP's result (7826) fails this test -- who's right? + // expect(fn.evaluate({ lat1: 35.6762, long1: 139.6503, lat2: -33.8688, long2: 151.2093 })).toBeCloseTo(7395, -1) + }) +}) diff --git a/v3/src/models/formula/functions/other-functions.ts b/v3/src/models/formula/functions/other-functions.ts index d1484f7e81..8f6f7a9eb0 100644 --- a/v3/src/models/formula/functions/other-functions.ts +++ b/v3/src/models/formula/functions/other-functions.ts @@ -1,14 +1,14 @@ import { pickRandom } from "mathjs" import { Random } from "random" -import { FValue } from "../formula-types" -import { isDate } from "../../../utilities/date-utils" import { isDateString, parseDate } from "../../../utilities/date-parser" -import { UNDEF_RESULT } from "./function-utils" +import { isDate } from "../../../utilities/date-utils" import { extractNumeric } from "../../../utilities/math-utils" +import { FValue, IFormulaMathjsFunction } from "../formula-types" +import { UNDEF_RESULT } from "./function-utils" const randomGen = new Random() -export const otherFunctions = { +export const otherFunctions: Record = { // if(expression, value_if_true, value_if_false) if: { numOfRequiredArguments: 3, @@ -29,7 +29,7 @@ export const otherFunctions = { numOfRequiredArguments: 0, isRandomFunction: true, // Nothing to do here, Random.float() has exactly the same signature as CODAP V2 random() function. - evaluate: (...args: FValue[]) => randomGen.float(...args.map(arg=>Number(arg))) + evaluate: (...args: FValue[]) => randomGen.float(...args.map(arg => Number(arg))) }, // randomNormal(mean, standard_deviation) Returns a random number drawn from a normal distribution which, by default, @@ -40,7 +40,7 @@ export const otherFunctions = { // We coerce the arguments to numbers. // randomGen.normal() has exactly the same signature as CODAP V2 randomNormal() function. evaluate: (...args: FValue[]) => { - return randomGen.normal(...args.map(arg=>Number(arg)))() + return randomGen.normal(...args.map(arg => Number(arg)))() } }, @@ -51,7 +51,7 @@ export const otherFunctions = { isRandomFunction: true, // Nothing to do here, randomGen.binomial() has exactly the same signature as CODAP V2 randomBinomial() function. evaluate: (...args: FValue[]) => { - return randomGen.binomial(...args.map(arg=>Number(arg)))() + return randomGen.binomial(...args.map(arg => Number(arg)))() } }, @@ -67,5 +67,33 @@ export const otherFunctions = { } return extractNumeric(arg) ?? UNDEF_RESULT } + }, + + /** + Returns the great circle distance between the two lat/long points on the earth's surface. + @param {Number} The latitude in degrees of the first point + @param {Number} The longitude in degrees of the first point + @param {Number} The latitude in degrees of the second point + @param {Number} The longitude in degrees of the second point + @returns {Number} The distance in kilometers between the two points + */ + greatCircleDistance: { + numOfRequiredArguments: 4, + evaluate: (...args: FValue[]) => { + const [_lat1, _long1, _lat2, _long2] = args + const lat1 = extractNumeric(_lat1) + const long1 = extractNumeric(_long1) + const lat2 = extractNumeric(_lat2) + const long2 = extractNumeric(_long2) + if (lat1 != null && long1 != null && lat2 != null && long2 != null) { + const deltaLat = lat2 - lat1 + const deltaLong = long2 - long1 + const a = Math.pow(Math.sin((Math.PI / 180) * deltaLat / 2), 2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.pow(Math.sin((Math.PI / 180) * deltaLong / 2), 2) + return 2 * 6371 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + } + return UNDEF_RESULT + } } } diff --git a/v3/src/models/formula/functions/string-functions.ts b/v3/src/models/formula/functions/string-functions.ts index 496c729b02..f8b8b78d52 100644 --- a/v3/src/models/formula/functions/string-functions.ts +++ b/v3/src/models/formula/functions/string-functions.ts @@ -81,7 +81,7 @@ export const stringFunctions = { } }, // join(delimiter, string1, string2, …) Returns the string formed by concatenating its string arguments, each - // seperated by its delimiter argument. + // separated by its delimiter argument. join: { numOfRequiredArguments: 2, evaluate: (...args: FValue[]) => { diff --git a/v3/src/utilities/math-utils.ts b/v3/src/utilities/math-utils.ts index afbbd02b1f..3b824a86da 100644 --- a/v3/src/utilities/math-utils.ts +++ b/v3/src/utilities/math-utils.ts @@ -143,7 +143,9 @@ export function isFiniteNumber(x: any): x is number { return x != null && Number.isFinite(x) } -export const isValueNonEmpty = (value: any) => value !== "" && value != null +export const isValueEmpty = (value: any) => value == null || value === "" + +export const isValueNonEmpty = (value: any) => !isValueEmpty(value) // Similar to isFiniteNumber, but looser. // It allows for strings that can be converted to numbers and treats Infinity and -Infinity as valid numbers.