diff --git a/app/Services/RuleEngineService.php b/app/Services/RuleEngineService.php index 2b912dc..8a04378 100644 --- a/app/Services/RuleEngineService.php +++ b/app/Services/RuleEngineService.php @@ -181,6 +181,15 @@ class RuleEngineService $testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null; } + $testCode = $action['testCode'] ?? null; + if ($testCode !== null) { + $resolvedId = $this->resolveTestSiteIdByCode($testCode); + if ($resolvedId === null) { + throw new \Exception('SET_RESULT unknown test code: ' . $testCode); + } + $testSiteID = $resolvedId; + } + if ($testSiteID === null) { throw new \Exception('SET_RESULT requires testSiteID'); } @@ -192,6 +201,8 @@ class RuleEngineService $value = $action['value'] ?? null; } + $testSiteCode = $testCode ?? $this->resolveTestSiteCode($testSiteID); + $db = \Config\Database::connect(); // Check if patres row exists @@ -214,7 +225,7 @@ class RuleEngineService $ok = $db->table('patres')->insert([ 'OrderID' => $internalOID, 'TestSiteID' => $testSiteID, - 'TestSiteCode' => $this->resolveTestSiteCode($testSiteID), + 'TestSiteCode' => $testSiteCode, 'Result' => $value, 'CreateDate' => date('Y-m-d H:i:s'), ]); @@ -360,4 +371,19 @@ class RuleEngineService return null; } } + + private function resolveTestSiteIdByCode(string $testSiteCode): ?int + { + try { + $testDefSiteModel = new TestDefSiteModel(); + $row = $testDefSiteModel->where('TestSiteCode', $testSiteCode)->where('EndDate', null)->first(); + if (empty($row['TestSiteID'])) { + return null; + } + + return (int) $row['TestSiteID']; + } catch (\Throwable $e) { + return null; + } + } } diff --git a/app/Services/RuleExpressionService.php b/app/Services/RuleExpressionService.php index 11bdba1..3d7c4bf 100644 --- a/app/Services/RuleExpressionService.php +++ b/app/Services/RuleExpressionService.php @@ -53,7 +53,8 @@ class RuleExpressionService * - priority('R'|'S'|'U') -> order["Priority"] == 'S' * - age > 18 -> age > 18 * - requested('CODE') -> requested('CODE') (resolved at runtime) - * - result_set(value) + * - result_set('TestSiteCode', value) + * - result_set(value) (deprecated, uses current context TestSiteID) * - test_insert('CODE') * - test_delete('CODE') * - comment_insert('text') @@ -242,9 +243,22 @@ class RuleExpressionService ]; } - // result_set(value) [aliases: set_result] - if (preg_match('/^(result_set|set_result)\s*\(\s*(.+?)\s*\)$/is', $action, $m)) { - $value = trim($m[2]); + // result_set(value) or result_set('CODE', value) [aliases: set_result] + if (preg_match('/^(result_set|set_result)\s*\(\s*(.+)\s*\)$/is', $action, $m)) { + $args = $this->splitTopLevel($m[2], ',', 2); + if (empty($args)) { + throw new \InvalidArgumentException('result_set requires a value expression'); + } + + $testCode = null; + if (count($args) === 2) { + $testCode = $this->unquoteStringArgument($args[0]); + if ($testCode === null) { + throw new \InvalidArgumentException('result_set test code must be a quoted string'); + } + } + + $value = trim($args[count($args) - 1]); // Check if it's a number if (is_numeric($value)) { @@ -252,6 +266,7 @@ class RuleExpressionService 'type' => 'RESULT_SET', 'value' => strpos($value, '.') !== false ? (float) $value : (int) $value, 'valueExpr' => $value, + 'testCode' => $testCode, ]; } @@ -261,6 +276,7 @@ class RuleExpressionService 'type' => 'RESULT_SET', 'value' => $vm[1], 'valueExpr' => '"' . addslashes($vm[1]) . '"', + 'testCode' => $testCode, ]; } @@ -268,6 +284,7 @@ class RuleExpressionService return [ 'type' => 'RESULT_SET', 'valueExpr' => $value, + 'testCode' => $testCode, ]; } @@ -666,4 +683,24 @@ class RuleExpressionService 'endIndex' => $start + 2, ]; } + + private function unquoteStringArgument(string $value): ?string + { + $value = trim($value); + if ($value === '') { + return null; + } + + $quote = $value[0]; + if ($quote !== '"' && $quote !== "'") { + return null; + } + + if (substr($value, -1) !== $quote) { + return null; + } + + $content = substr($value, 1, -1); + return stripcslashes($content); + } } diff --git a/docs/calculator-operators.md b/docs/calculator-operators.md new file mode 100644 index 0000000..1e289af --- /dev/null +++ b/docs/calculator-operators.md @@ -0,0 +1,309 @@ +# Calculator Service Operators Reference + +## Overview + +The `CalculatorService` (`app/Services/CalculatorService.php`) uses the [mossadal/math-parser](https://github.com/mossadal/math-parser) library to safely evaluate mathematical expressions. This document lists all available operators, functions, and constants. + +--- + +## Supported Operators + +### Arithmetic Operators + +| Operator | Description | Example | Result | +|----------|-------------|---------|--------| +| `+` | Addition | `5 + 3` | `8` | +| `-` | Subtraction | `10 - 4` | `6` | +| `*` | Multiplication | `6 * 7` | `42` | +| `/` | Division | `20 / 4` | `5` | +| `^` | Exponentiation (power) | `2 ^ 3` | `8` | +| `!` | Factorial | `5!` | `120` | +| `!!` | Semi-factorial (double factorial) | `5!!` | `15` | + +### Parentheses + +Use parentheses to control operation precedence: + +``` +(2 + 3) * 4 // Result: 20 +2 + 3 * 4 // Result: 14 +``` + +--- + +## Mathematical Functions + +### Rounding Functions + +| Function | Description | Example | +|----------|-------------|---------| +| `sqrt(x)` | Square root | `sqrt(16)` → `4` | +| `round(x)` | Round to nearest integer | `round(3.7)` → `4` | +| `ceil(x)` | Round up to integer | `ceil(3.2)` → `4` | +| `floor(x)` | Round down to integer | `floor(3.9)` → `3` | +| `abs(x)` | Absolute value | `abs(-5)` → `5` | +| `sgn(x)` | Sign function | `sgn(-10)` → `-1` | + +### Trigonometric Functions (Radians) + +| Function | Description | Example | +|----------|-------------|---------| +| `sin(x)` | Sine | `sin(pi/2)` → `1` | +| `cos(x)` | Cosine | `cos(0)` → `1` | +| `tan(x)` | Tangent | `tan(pi/4)` → `1` | +| `cot(x)` | Cotangent | `cot(pi/4)` → `1` | + +### Trigonometric Functions (Degrees) + +| Function | Description | Example | +|----------|-------------|---------| +| `sind(x)` | Sine (degrees) | `sind(90)` → `1` | +| `cosd(x)` | Cosine (degrees) | `cosd(0)` → `1` | +| `tand(x)` | Tangent (degrees) | `tand(45)` → `1` | +| `cotd(x)` | Cotangent (degrees) | `cotd(45)` → `1` | + +### Hyperbolic Functions + +| Function | Description | Example | +|----------|-------------|---------| +| `sinh(x)` | Hyperbolic sine | `sinh(1)` → `1.175...` | +| `cosh(x)` | Hyperbolic cosine | `cosh(1)` → `1.543...` | +| `tanh(x)` | Hyperbolic tangent | `tanh(1)` → `0.761...` | +| `coth(x)` | Hyperbolic cotangent | `coth(2)` → `1.037...` | + +### Inverse Trigonometric Functions + +| Function | Aliases | Description | Example | +|----------|---------|-------------|---------| +| `arcsin(x)` | `asin(x)` | Inverse sine | `arcsin(0.5)` → `0.523...` | +| `arccos(x)` | `acos(x)` | Inverse cosine | `arccos(0.5)` → `1.047...` | +| `arctan(x)` | `atan(x)` | Inverse tangent | `arctan(1)` → `0.785...` | +| `arccot(x)` | `acot(x)` | Inverse cotangent | `arccot(1)` → `0.785...` | + +### Inverse Hyperbolic Functions + +| Function | Aliases | Description | Example | +|----------|---------|-------------|---------| +| `arsinh(x)` | `asinh(x)`, `arcsinh(x)` | Inverse hyperbolic sine | `arsinh(1)` → `0.881...` | +| `arcosh(x)` | `acosh(x)`, `arccosh(x)` | Inverse hyperbolic cosine | `arcosh(2)` → `1.316...` | +| `artanh(x)` | `atanh(x)`, `arctanh(x)` | Inverse hyperbolic tangent | `artanh(0.5)` → `0.549...` | +| `arcoth(x)` | `acoth(x)`, `arccoth(x)` | Inverse hyperbolic cotangent | `arcoth(2)` → `0.549...` | + +### Logarithmic & Exponential Functions + +| Function | Aliases | Description | Example | +|----------|---------|-------------|---------| +| `exp(x)` | - | Exponential (e^x) | `exp(2)` → `7.389...` | +| `log(x)` | `ln(x)` | Natural logarithm (base e) | `log(e)` → `1` | +| `log10(x)` | `lg(x)` | Logarithm base 10 | `log10(100)` → `2` | + +--- + +## Constants + +| Constant | Value | Description | Example | +|----------|-------|-------------|---------| +| `pi` | 3.14159265... | Ratio of circle circumference to diameter | `pi * r ^ 2` | +| `e` | 2.71828182... | Euler's number | `e ^ x` | +| `NAN` | Not a Number | Invalid mathematical result | - | +| `INF` | Infinity | Positive infinity | - | + +--- + +## Variables in CalculatorService + +When using `calculateFromDefinition()`, the following variables are automatically available: + +| Variable | Description | Type | +|----------|-------------|------| +| `{result}` | The test result value | Float | +| `{factor}` | Calculation factor (default: 1) | Float | +| `{gender}` | Gender value (0=Unknown, 1=Female, 2=Male) | Integer | +| `{age}` | Patient age | Float | +| `{ref_low}` | Reference range low value | Float | +| `{ref_high}` | Reference range high value | Float | + +### Gender Mapping + +The `gender` variable accepts the following values: + +| Value | Description | +|-------|-------------| +| `0` | Unknown | +| `1` | Female | +| `2` | Male | + +Or use string values: `'unknown'`, `'female'`, `'male'` + +--- + +## Implicit Multiplication + +The parser supports implicit multiplication (no explicit `*` operator needed): + +| Expression | Parsed As | Result (x=2, y=3) | +|------------|-----------|-------------------| +| `2x` | `2 * x` | `4` | +| `x sin(x)` | `x * sin(x)` | `1.818...` | +| `2xy` | `2 * x * y` | `12` | +| `x^2y` | `x^2 * y` | `12` | + +**Note:** Implicit multiplication has the same precedence as explicit multiplication. `xy^2z` is parsed as `x*y^2*z`, NOT as `x*y^(2*z)`. + +--- + +## Usage Examples + +### Basic Calculation + +```php +use App\Services\CalculatorService; + +$calculator = new CalculatorService(); + +// Simple arithmetic +$result = $calculator->calculate("5 + 3 * 2"); +// Result: 11 + +// Using functions +$result = $calculator->calculate("sqrt(16) + abs(-5)"); +// Result: 9 + +// Using constants +$result = $calculator->calculate("2 * pi * r", ['r' => 5]); +// Result: 31.415... +``` + +### With Variables + +```php +$formula = "{result} * {factor} + 10"; +$variables = [ + 'result' => 5.2, + 'factor' => 2 +]; + +$result = $calculator->calculate($formula, $variables); +// Result: 20.4 +``` + +### BMI Calculation + +```php +$formula = "{weight} / ({height} ^ 2)"; +$variables = [ + 'weight' => 70, // kg + 'height' => 1.75 // meters +]; + +$result = $calculator->calculate($formula, $variables); +// Result: 22.86 +``` + +### Gender-Based Calculation + +```php +// Apply different multipliers based on gender +$formula = "{result} * (1 + 0.1 * {gender})"; +$variables = [ + 'result' => 100, + 'gender' => 1 // Female = 1 +]; + +$result = $calculator->calculate($formula, $variables); +// Result: 110 +``` + +### Complex Formula + +```php +// Pythagorean theorem with rounding +$formula = "round(sqrt({a} ^ 2 + {b} ^ 2))"; +$variables = [ + 'a' => 3, + 'b' => 4 +]; + +$result = $calculator->calculate($formula, $variables); +// Result: 5 +``` + +### Using calculateFromDefinition + +```php +$calcDef = [ + 'FormulaCode' => '{result} * {factor} + {gender}', + 'Factor' => 2 +]; + +$testValues = [ + 'result' => 10, + 'gender' => 1 // Female +]; + +$result = $calculator->calculateFromDefinition($calcDef, $testValues); +// Result: 21 (10 * 2 + 1) +``` + +--- + +## Formula Validation + +Validate formulas before storing them: + +```php +$validation = $calculator->validate("{result} / {factor}"); +// Returns: ['valid' => true, 'error' => null] + +$validation = $calculator->validate("{result} /"); +// Returns: ['valid' => false, 'error' => 'Error message'] +``` + +### Extract Variables + +Get a list of variables used in a formula: + +```php +$variables = $calculator->extractVariables("{result} * {factor} + {age}"); +// Returns: ['result', 'factor', 'age'] +``` + +--- + +## Error Handling + +The service throws exceptions for invalid formulas or missing variables: + +```php +try { + $result = $calculator->calculate("{result} / 0"); +} catch (\Exception $e) { + // Handle division by zero or other errors + log_message('error', $e->getMessage()); +} +``` + +Common errors: + +- **Invalid formula syntax**: Malformed expressions +- **Missing variable**: Variable placeholder not provided in data array +- **Non-numeric value**: Variables must be numeric +- **Division by zero**: Mathematical error + +--- + +## Best Practices + +1. **Always validate formulas** before storing in database +2. **Use placeholder syntax** `{variable_name}` for clarity +3. **Handle exceptions** in production code +4. **Test edge cases** like zero values and boundary conditions +5. **Document formulas** with comments in your code + +--- + +## References + +- [math-parser GitHub](https://github.com/mossadal/math-parser) +- [math-parser Documentation](http://mossadal.github.io/math-parser/) +- `app/Services/CalculatorService.php` diff --git a/docs/test-rule-engine.md b/docs/test-rule-engine.md index f3e6d18..074d37a 100644 --- a/docs/test-rule-engine.md +++ b/docs/test-rule-engine.md @@ -90,7 +90,8 @@ Each rule compiles and runs independently when its trigger fires and the test is | Action | Description | Example | |--------|-------------|---------| -| `result_set(value)` | Write to `patres.Result` for the current order/test using the provided value | `result_set(0.5)` | +| `result_set(value)` | (deprecated) Write to `patres.Result` for the current context test | `result_set(0.5)` | +| `result_set('CODE', value)` | Target a specific test by `TestSiteCode`, allowing multiple tests to be updated in one rule | `result_set('tesA', 0.5)` | | `test_insert('CODE')` | Insert a test row by `TestSiteCode` if it doesn’t already exist for the order | `test_insert('HBA1C')` | | `test_delete('CODE')` | Remove a previously requested test from the current order when the rule deems it unnecessary | `test_delete('INS')` | | `comment_insert('text')` | Insert an order comment (`ordercom`) describing priority or clinical guidance | `comment_insert('Male patient - review')` | @@ -102,15 +103,15 @@ Each rule compiles and runs independently when its trigger fires and the test is 1. **Compiled expression required:** Rules without `ConditionExprCompiled` are ignored (see `RuleEngineService::run`). 2. **Order context:** `context.order.InternalOID` must exist for any action that writes to `patres` or `ordercom`. -3. **TestSiteID:** `result_set()` needs `testSiteID` (either provided in context or from `order.TestSiteID`). `test_insert()` resolves a `TestSiteID` via the `TestSiteCode` in `TestDefSiteModel`, and `test_delete()` removes the matching `TestSiteID` rows when needed. +3. **TestSiteID:** `result_set()` needs `testSiteID` (either provided in context or from `order.TestSiteID`). When you provide a `TestSiteCode` as the first argument (`result_set('tesA', value)`), the engine resolves that code before writing the result. `test_insert()` resolves a `TestSiteID` via the `TestSiteCode` in `TestDefSiteModel`, and `test_delete()` removes the matching `TestSiteID` rows when needed. 4. **Requested check:** `requested('CODE')` inspects `patres` rows for the same `OrderID` and `TestSiteCode`. ## Examples ``` -if(sex('M'); result_set(0.5); result_set(0.6)) +if(sex('M'); result_set('tesA', 0.5):result_set('tesB', 1.2); result_set('tesA', 0.6):result_set('tesB', 1.0)) ``` -Returns `0.5` for males, `0.6` otherwise. +Sets both `tesA`/`tesB` results together per branch. ``` if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing) diff --git a/issues.md b/issues.md deleted file mode 100644 index 64de5ab..0000000 --- a/issues.md +++ /dev/null @@ -1 +0,0 @@ -[ ] account initial must be unique