Skip to content

Commit

Permalink
feat: implement logical functions and greatCircleDistance function (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
kswenson authored Oct 23, 2024
1 parent 5b9d21c commit cdd1b3a
Show file tree
Hide file tree
Showing 7 changed files with 329 additions and 20 deletions.
177 changes: 177 additions & 0 deletions v3/src/models/formula/functions/logic-functions.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
75 changes: 75 additions & 0 deletions v3/src/models/formula/functions/logic-functions.ts
Original file line number Diff line number Diff line change
@@ -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<string, IFormulaMathjsFunction> = {

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))
}
}
}
25 changes: 14 additions & 11 deletions v3/src/models/formula/functions/math.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -69,6 +70,8 @@ export const fnRegistry = {

...arithmeticFunctions,

...logicFunctions,

...dateFunctions,

...stringFunctions,
Expand Down
24 changes: 24 additions & 0 deletions v3/src/models/formula/functions/other-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading

0 comments on commit cdd1b3a

Please sign in to comment.