Skip to content

Commit ef8890b

Browse files
committed
feat: implement trigonometric equation solving in solve() method with corresponding tests
1 parent 32793f8 commit ef8890b

3 files changed

Lines changed: 296 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@
5050
- `1 + cot²(x)``csc²(x)` and `csc²(x) - 1``cot²(x)`
5151
- `a·sin²(x) + a·cos²(x)``a` (with coefficient)
5252

53+
- **Trigonometric Equation Solving**: The `solve()` method now handles basic
54+
trigonometric equations:
55+
- `sin(x) = a``x = arcsin(a)` and `x = π - arcsin(a)` (two solutions)
56+
- `cos(x) = a``x = arccos(a)` and `x = -arccos(a)` (two solutions)
57+
- `tan(x) = a``x = arctan(a)` (one solution per period)
58+
- `cot(x) = a``x = arccot(a)`
59+
- Supports coefficient form: `a·sin(x) + b = 0`
60+
- Domain validation: returns no solutions when |a| > 1 for sin/cos
61+
- Automatic deduplication of equivalent solutions (e.g., `cos(x) = 1` → single solution `0`)
62+
5363
- **([#133](https://github.com/cortex-js/compute-engine/issues/133)) Element-based
5464
Indexing Sets for Sum/Product**: Added support for `\in` notation in summation
5565
and product subscripts:

src/compute-engine/boxed-expression/solve.ts

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,187 @@ export const UNIVARIATE_ROOTS: Rule[] = [
332332
useVariations: true,
333333
condition: filter,
334334
},
335+
336+
//
337+
// Trigonometric equations
338+
//
339+
// Note: These return principal values only. For general solutions,
340+
// add 2πn for sin/cos or πn for tan (where n ∈ ℤ).
341+
//
342+
343+
// a·sin(x) + b = 0 => x = arcsin(-b/a)
344+
// Valid when -1 ≤ -b/a ≤ 1
345+
{
346+
match: ['Add', ['Multiply', '__a', ['Sin', '_x']], '__b'],
347+
replace: ['Arcsin', ['Divide', ['Negate', '__b'], '__a']],
348+
useVariations: true,
349+
condition: (sub) => {
350+
if (!filter(sub)) return false;
351+
// Check that -b/a is in [-1, 1] for real solutions
352+
const a = sub.__a;
353+
const b = sub.__b;
354+
if (!a || a.is(0)) return false;
355+
const ratio = b.div(a).neg();
356+
const val = ratio.numericValue;
357+
if (val === null) return true; // Allow symbolic ratios
358+
if (typeof val === 'number') return Math.abs(val) <= 1;
359+
return true;
360+
},
361+
},
362+
363+
// Second solution for sin: x = π - arcsin(-b/a)
364+
{
365+
match: ['Add', ['Multiply', '__a', ['Sin', '_x']], '__b'],
366+
replace: ['Subtract', 'Pi', ['Arcsin', ['Divide', ['Negate', '__b'], '__a']]],
367+
useVariations: true,
368+
condition: (sub) => {
369+
if (!filter(sub)) return false;
370+
const a = sub.__a;
371+
const b = sub.__b;
372+
if (!a || a.is(0)) return false;
373+
const ratio = b.div(a).neg();
374+
const val = ratio.numericValue;
375+
if (val === null) return true;
376+
if (typeof val === 'number') return Math.abs(val) <= 1;
377+
return true;
378+
},
379+
},
380+
381+
// sin(x) + b = 0 => x = arcsin(-b) (when a = 1)
382+
{
383+
match: ['Add', ['Sin', '_x'], '__b'],
384+
replace: ['Arcsin', ['Negate', '__b']],
385+
useVariations: true,
386+
condition: (sub) => {
387+
if (!filter(sub)) return false;
388+
const b = sub.__b;
389+
const val = b.numericValue;
390+
if (val === null) return true;
391+
if (typeof val === 'number') return Math.abs(val) <= 1;
392+
return true;
393+
},
394+
},
395+
396+
// Second solution for sin(x) + b = 0: x = π - arcsin(-b)
397+
{
398+
match: ['Add', ['Sin', '_x'], '__b'],
399+
replace: ['Subtract', 'Pi', ['Arcsin', ['Negate', '__b']]],
400+
useVariations: true,
401+
condition: (sub) => {
402+
if (!filter(sub)) return false;
403+
const b = sub.__b;
404+
const val = b.numericValue;
405+
if (val === null) return true;
406+
if (typeof val === 'number') return Math.abs(val) <= 1;
407+
return true;
408+
},
409+
},
410+
411+
// a·cos(x) + b = 0 => x = arccos(-b/a)
412+
// Valid when -1 ≤ -b/a ≤ 1
413+
{
414+
match: ['Add', ['Multiply', '__a', ['Cos', '_x']], '__b'],
415+
replace: ['Arccos', ['Divide', ['Negate', '__b'], '__a']],
416+
useVariations: true,
417+
condition: (sub) => {
418+
if (!filter(sub)) return false;
419+
const a = sub.__a;
420+
const b = sub.__b;
421+
if (!a || a.is(0)) return false;
422+
const ratio = b.div(a).neg();
423+
const val = ratio.numericValue;
424+
if (val === null) return true;
425+
if (typeof val === 'number') return Math.abs(val) <= 1;
426+
return true;
427+
},
428+
},
429+
430+
// Second solution for cos: x = -arccos(-b/a) (since cos(-x) = cos(x))
431+
{
432+
match: ['Add', ['Multiply', '__a', ['Cos', '_x']], '__b'],
433+
replace: ['Negate', ['Arccos', ['Divide', ['Negate', '__b'], '__a']]],
434+
useVariations: true,
435+
condition: (sub) => {
436+
if (!filter(sub)) return false;
437+
const a = sub.__a;
438+
const b = sub.__b;
439+
if (!a || a.is(0)) return false;
440+
const ratio = b.div(a).neg();
441+
const val = ratio.numericValue;
442+
if (val === null) return true;
443+
if (typeof val === 'number') return Math.abs(val) <= 1;
444+
return true;
445+
},
446+
},
447+
448+
// cos(x) + b = 0 => x = arccos(-b) (when a = 1)
449+
{
450+
match: ['Add', ['Cos', '_x'], '__b'],
451+
replace: ['Arccos', ['Negate', '__b']],
452+
useVariations: true,
453+
condition: (sub) => {
454+
if (!filter(sub)) return false;
455+
const b = sub.__b;
456+
const val = b.numericValue;
457+
if (val === null) return true;
458+
if (typeof val === 'number') return Math.abs(val) <= 1;
459+
return true;
460+
},
461+
},
462+
463+
// Second solution for cos(x) + b = 0: x = -arccos(-b)
464+
{
465+
match: ['Add', ['Cos', '_x'], '__b'],
466+
replace: ['Negate', ['Arccos', ['Negate', '__b']]],
467+
useVariations: true,
468+
condition: (sub) => {
469+
if (!filter(sub)) return false;
470+
const b = sub.__b;
471+
const val = b.numericValue;
472+
if (val === null) return true;
473+
if (typeof val === 'number') return Math.abs(val) <= 1;
474+
return true;
475+
},
476+
},
477+
478+
// a·tan(x) + b = 0 => x = arctan(-b/a)
479+
// Tan has no domain restriction for the ratio
480+
{
481+
match: ['Add', ['Multiply', '__a', ['Tan', '_x']], '__b'],
482+
replace: ['Arctan', ['Divide', ['Negate', '__b'], '__a']],
483+
useVariations: true,
484+
condition: (sub) => {
485+
if (!filter(sub)) return false;
486+
return !sub.__a.is(0);
487+
},
488+
},
489+
490+
// tan(x) + b = 0 => x = arctan(-b) (when a = 1)
491+
{
492+
match: ['Add', ['Tan', '_x'], '__b'],
493+
replace: ['Arctan', ['Negate', '__b']],
494+
useVariations: true,
495+
condition: filter,
496+
},
497+
498+
// a·cot(x) + b = 0 => x = arccot(-b/a)
499+
{
500+
match: ['Add', ['Multiply', '__a', ['Cot', '_x']], '__b'],
501+
replace: ['Arccot', ['Divide', ['Negate', '__b'], '__a']],
502+
useVariations: true,
503+
condition: (sub) => {
504+
if (!filter(sub)) return false;
505+
return !sub.__a.is(0);
506+
},
507+
},
508+
509+
// cot(x) + b = 0 => x = arccot(-b)
510+
{
511+
match: ['Add', ['Cot', '_x'], '__b'],
512+
replace: ['Arccot', ['Negate', '__b']],
513+
useVariations: true,
514+
condition: filter,
515+
},
335516
];
336517

337518
/**
@@ -626,7 +807,7 @@ function validateRoots(
626807
x: string,
627808
roots: ReadonlyArray<BoxedExpression>
628809
): BoxedExpression[] {
629-
return roots.filter((root) => {
810+
const validRoots = roots.filter((root) => {
630811
// Evaluate the expression at the root
631812
const value = expr.subs({ [x]: root }).canonical.evaluate();
632813
if (value === null) return false;
@@ -638,4 +819,15 @@ function validateRoots(
638819
// The former accounts for tolerance, the latter does not
639820
return value.isEqual(0);
640821
});
822+
823+
// Deduplicate roots (e.g., arccos(1) and -arccos(1) both equal 0)
824+
const uniqueRoots: BoxedExpression[] = [];
825+
for (const root of validRoots) {
826+
const isDuplicate = uniqueRoots.some(
827+
(existing) => existing.isSame(root) || existing.isEqual(root)
828+
);
829+
if (!isDuplicate) uniqueRoots.push(root);
830+
}
831+
832+
return uniqueRoots;
641833
}

test/compute-engine/solve.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,96 @@ describe('SOLVING SQRT AND LN EQUATIONS', () => {
319319
expect(result?.[0]).toMatch(/^7\.389/);
320320
});
321321
});
322+
323+
// Tests for trigonometric equations
324+
describe('SOLVING TRIGONOMETRIC EQUATIONS', () => {
325+
test('should solve sin(x) = 0', () => {
326+
const e = expr('\\sin(x) = 0');
327+
const result = e.solve('x')?.map((x) => x.json);
328+
// Principal solutions: 0 and π
329+
expect(result).toMatchInlineSnapshot(`
330+
[
331+
0,
332+
Pi,
333+
]
334+
`);
335+
});
336+
337+
test('should solve sin(x) = 1/2', () => {
338+
const e = expr('\\sin(x) = 1/2');
339+
const result = e.solve('x')?.map((x) => x.N().json);
340+
// Principal solutions: π/6 ≈ 0.5236 and 5π/6 ≈ 2.618
341+
expect(result?.length).toBe(2);
342+
expect((result?.[0] as { num: string }).num).toMatch(/^0\.523/);
343+
});
344+
345+
test('should solve cos(x) = 0', () => {
346+
const e = expr('\\cos(x) = 0');
347+
const result = e.solve('x')?.map((x) => x.N().json);
348+
// Principal solutions: π/2 ≈ 1.571 and -π/2 ≈ -1.571
349+
expect(result?.length).toBe(2);
350+
expect((result?.[0] as { num: string }).num).toMatch(/^1\.570/);
351+
});
352+
353+
test('should solve cos(x) = 1', () => {
354+
const e = expr('\\cos(x) = 1');
355+
const result = e.solve('x')?.map((x) => x.json);
356+
// Principal solution: 0 (deduplicated from arccos(1) and -arccos(1))
357+
expect(result).toMatchInlineSnapshot(`
358+
[
359+
0,
360+
]
361+
`);
362+
});
363+
364+
test('should solve tan(x) = 1', () => {
365+
const e = expr('\\tan(x) = 1');
366+
const result = e.solve('x')?.map((x) => x.N().json);
367+
// Principal solution: π/4 ≈ 0.7854
368+
expect(result?.length).toBe(1);
369+
expect((result?.[0] as { num: string }).num).toMatch(/^0\.785/);
370+
});
371+
372+
test('should solve tan(x) = 0', () => {
373+
const e = expr('\\tan(x) = 0');
374+
const result = e.solve('x')?.map((x) => x.json);
375+
// Principal solution: 0
376+
expect(result).toMatchInlineSnapshot(`
377+
[
378+
0,
379+
]
380+
`);
381+
});
382+
383+
test('should solve 2sin(x) - 1 = 0', () => {
384+
const e = expr('2\\sin(x) - 1 = 0');
385+
const result = e.solve('x')?.map((x) => x.N().json);
386+
// Same as sin(x) = 1/2
387+
expect(result?.length).toBe(2);
388+
expect((result?.[0] as { num: string }).num).toMatch(/^0\.523/);
389+
});
390+
391+
test('should solve 3cos(x) + 3 = 0', () => {
392+
const e = expr('3\\cos(x) + 3 = 0');
393+
const result = e.solve('x')?.map((x) => x.N().json);
394+
// cos(x) = -1, solution: π ≈ 3.1416 and -π
395+
expect(result?.length).toBe(2);
396+
// Check one is π and one is -π
397+
const values = result?.map((r) => parseFloat((r as { num: string }).num));
398+
expect(values?.some((v) => Math.abs(v - Math.PI) < 0.001)).toBe(true);
399+
});
400+
401+
test('should return empty for sin(x) = 2 (no real solution)', () => {
402+
const e = expr('\\sin(x) = 2');
403+
const result = e.solve('x');
404+
// sin(x) can only be in [-1, 1]
405+
expect(result).toEqual([]);
406+
});
407+
408+
test('should return empty for cos(x) = -2 (no real solution)', () => {
409+
const e = expr('\\cos(x) = -2');
410+
const result = e.solve('x');
411+
// cos(x) can only be in [-1, 1]
412+
expect(result).toEqual([]);
413+
});
414+
});

0 commit comments

Comments
 (0)