diff --git a/docs/sensoryZPP-architecture.md b/docs/sensoryZPP-architecture.md new file mode 100644 index 0000000..ec4fd36 --- /dev/null +++ b/docs/sensoryZPP-architecture.md @@ -0,0 +1,326 @@ +# Sensory ZPP Step-Based Architecture + +**Author:** ISNCSCI Architect Agent +**Date:** 2025-02-17 +**Status:** Architecture proposal for refactor + +--- + +## 1. High-Level Overview + +### What the module computes + +The **Sensory Zone of Partial Preservation (Sensory ZPP)** identifies the most caudal segment with preserved sensory function (light touch and pin prick) when sacral sparing is absent or not testable. It answers: _"What is the lowest level with any preserved sensation?"_ + +### Role in the ISNCSCI algorithm + +- Sensory ZPP is reported when **Deep Anal Pressure (DAP)** is `No` or `NT` and S4-5 sensory values indicate absent sensation. +- When DAP is `Yes` or when S4-5 has preserved sensation, Sensory ZPP is `NA`. +- The algorithm iterates from S3 down to C1, checking each level for preserved sensory function until it finds the caudal boundary. + +### Key inputs and final outputs + +| Input | Type | Description | +| ------------------ | ------------------- | --------------------------------------------- | +| `side` | `ExamSide` | Exam data (lightTouch, pinPrick) for one side | +| `deepAnalPressure` | `BinaryObservation` | DAP: `Yes`, `No`, or `NT` | + +| Output | Type | Description | +| ----------- | -------- | ------------------------------------------------------------- | +| Sensory ZPP | `string` | Comma-separated levels (e.g. `"S3,S2,S1"`, `"NA"`, `"NA,S3"`) | + +--- + +## 2. List of Steps (in order) + +1. **checkIfSensoryZPPIsApplicable** – Gate: return NA immediately if DAP is Yes or S4-5 has preserved sensation. +2. **checkSacralLevel** – Evaluate S4-5; optionally add NA to zpp based on DAP and sacral result. +3. **getTopAndBottomLevelsForCheck** – Set up iteration range (S3 down to C1) and initialize current level. +4. **checkLevel** – For each level, run sensory check; add level to zpp, update variable flag, continue or stop. +5. **sortSensoryZPP** – Sort results with NA first if present; final step. + +--- + +## 3. Step Definitions + +### Step 1: checkIfSensoryZPPIsApplicable + +| Field | Description | +| --------------- | ------------------------------------------------------------------------------------ | +| **name** | `checkIfSensoryZPPIsApplicable` | +| **purpose** | Determine if Sensory ZPP applies or if we return `NA` immediately. | +| **inputs** | `state.deepAnalPressure`, `state.side.lightTouch.S4_5`, `state.side.pinPrick.S4_5` | +| **outputs** | `state.zpp` (empty or `['NA']`), `state.next` | +| **explanation** | "Check if Deep Anal Pressure and S4-5 sensory values allow Sensory ZPP calculation." | + +**Logic:** + +- If DAP is `Yes` → `zpp = ['NA']`, `next = null` (stop). +- If DAP is `No` or `NT` and either S4-5 LT or PP is _not_ `canBeAbsentSensory` → `zpp = ['NA']`, `next = null` (stop). +- Else → `zpp = []`, `variable = false`, `next = checkSacralLevel`. + +--- + +### Step 2: checkSacralLevel + +| Field | Description | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **name** | `checkSacralLevel` | +| **purpose** | Evaluate S4-5 with `checkLevelForSensoryZPP`; add NA to zpp when DAP is NT or when DAP is No and sacral result indicates NA. | +| **inputs** | `state.side`, `state.deepAnalPressure`, `state.variable` | +| **outputs** | `state.zpp` (variable unchanged; sacral result not propagated) | +| **explanation** | "Evaluate S4-5 sensory values. Add NA to Sensory ZPP when DAP is NT or when DAP is No and sacral sparing is absent or partial." | + +**Logic:** + +- Call `checkLevelForSensoryZPP(side, 'S4_5', variable)`. +- If DAP is `NT` → push `'NA'` to zpp. +- If DAP is `No` and (`!sacralResult.continue` or `sacralResult.level !== undefined`) → push `'NA'` to zpp. +- Do **not** propagate `sacralResult.variable`; keep `variable` unchanged (original did not pass sacral variable into S3→C1 loop). +- `next = getTopAndBottomLevelsForCheck`. + +--- + +### Step 3: getTopAndBottomLevelsForCheck + +| Field | Description | +| --------------- | ---------------------------------------------------------------------------------------- | +| **name** | `getTopAndBottomLevelsForCheck` | +| **purpose** | Define iteration range (S3 → C1) and set `currentLevel` to S3. | +| **inputs** | `state.side` | +| **outputs** | `state.topLevel`, `state.bottomLevel`, `state.currentLevel` | +| **explanation** | "Set the search range from S3 (top) to C1 (bottom). We will iterate from S3 down to C1." | + +**Logic:** + +- `topLevel` = S3, `bottomLevel` = C1. +- Build a chain of sensory-only levels (no motor) from S3 down to C1. +- `currentLevel = topLevel` (S3). +- `next = checkLevel`. + +--- + +### Step 4: checkLevel + +| Field | Description | +| --------------- | ---------------------------------------------------------------------------------------------------------------- | +| **name** | `checkLevel` | +| **purpose** | For the current level, run `checkLevelForSensoryZPP`; add level to zpp if indicated; continue or stop iteration. | +| **inputs** | `state.currentLevel`, `state.side`, `state.variable`, `state.zpp` | +| **outputs** | `state.zpp`, `state.variable`, `state.currentLevel`, `state.next` | +| **explanation** | "Check sensory function at {{levelName}} (LT: {{lightTouch}}, PP: {{pinPrick}})." | + +**Logic:** + +- If `currentLevel` is C1 → add C1 to zpp (per ISNCSCI: C1 is always added when iteration completes), `next = sortSensoryZPP`. +- Else: + - Call `checkLevelForSensoryZPP(side, currentLevel.name, variable)`. + - `variable = variable || result.variable`. + - If `result.level` → push/append `result.level` to zpp. + - If `result.continue` → advance `currentLevel` to next level down (S3→S2→…→C2), `next = checkLevel`. + - If `!result.continue` → sensory boundary found; do **not** add C1; `next = sortSensoryZPP`. +- Advance: `currentLevel` becomes the next level down (e.g. S3 → S2 → … → C2 → C1). + +--- + +### Step 5: sortSensoryZPP + +| Field | Description | +| --------------- | ---------------------------------------------------------------------- | +| **name** | `sortSensoryZPP` | +| **purpose** | Sort zpp so NA is first (if present); then by level order. Final step. | +| **inputs** | `state.zpp` | +| **outputs** | `state.zpp` | +| **explanation** | "Sort Sensory ZPP. Ensure NA is placed first if present." | + +**Logic:** + +- Same as `sortMotorZPP`: NA first, then levels by SensoryLevels index. +- `next = null`. + +--- + +## 4. Proposed Folder/File Structure + +``` +src/classification/ +├── common/ +│ ├── step.ts # Shared Step, StepHandler, createStep +│ └── index.ts +├── zoneOfPartialPreservation/ +│ ├── motorZPP.ts # Uses shared Step/StepHandler +│ ├── motorZPPErrors.ts +│ ├── motorZPP.spec.ts +│ ├── sensoryZPP.ts # Main entry, step chain +│ ├── sensoryZPPErrors.ts # Error types and messages +│ ├── sensoryZPP.spec.ts +│ └── sensoryZPPSupport.ts # checkLevelForSensoryZPP, buildSensoryLevelChain, etc. +``` + +### Shared vs module-specific + +| Item | Location | Rationale | +| ------------------------- | ----------------------------------------- | ---------------------------------------------------- | +| `Step` type | `common/step.ts` | Reusable across motorZPP, sensoryZPP, future modules | +| `StepHandler` | `common/step.ts` | Generic handler `(state: S) => Step` | +| `createStep` | `common/step.ts` | Shared helper | +| `State` | `sensoryZPP.ts` | Sensory-specific state shape | +| `checkLevelForSensoryZPP` | `sensoryZPPSupport.ts` or `sensoryZPP.ts` | Pure support; keep in sensory module | + +--- + +## 5. Shared Interfaces + +### 5.1 Common step module (`common/step.ts`) + +```typescript +import { Translation } from '../common'; + +export type StepDescription = { + key: Translation; + params?: { [key: string]: string }; +}; +export type StepAction = { + key: Translation; + params?: { [key: string]: string }; +}; + +export type Step = { + description: StepDescription; + actions: StepAction[]; + next: StepHandler | null; + state: S; +}; + +export type StepHandler = (state: S) => Step; + +export function createStep( + description: Step['description'], + actions: Step['actions'], + state: S, + updates: Partial, + next: Step['next'], +): Step { + return { + description, + actions, + state: { ...state, ...updates }, + next, + }; +} +``` + +### 5.2 Sensory ZPP state + +```typescript +export type SensoryZPPState = { + side: ExamSide; + deepAnalPressure: BinaryObservation; + zpp: string[]; + variable: boolean; + topLevel: SensoryLevelNode | null; + bottomLevel: SensoryLevelNode | null; + currentLevel: SensoryLevelNode | null; +}; + +export type SensoryLevelNode = { + name: SensoryLevel; + lightTouch: SensoryPointValue; + pinPrick: SensoryPointValue; + index: number; + next: SensoryLevelNode | null; // next level down (e.g. S3 → S2) + previous: SensoryLevelNode | null; +}; +``` + +### 5.3 Sensory ZPP errors + +```typescript +// sensoryZPPErrors.ts +export const SENSORY_ZPP_ERROR_MESSAGES = { + CHECK_LEVEL_C1_INVALID: 'checkLevelForSensoryZPP :: invalid argument level: C1', + CURRENT_LEVEL_REQUIRED: 'checkLevel :: state.currentLevel is required.', +} as const; + +export class SensoryZPPError extends Error { ... } +``` + +--- + +## 6. Reusability: Step and StepHandler + +### Current motorZPP usage + +- `Step` and `StepHandler` are defined locally in `motorZPP.ts`. +- `createStep` is a local helper. + +### Proposed refactor + +1. **Extract** `Step`, `StepHandler`, and `createStep` to `src/classification/common/step.ts`. +2. **Make generic** so `Step` and `StepHandler` work with any state type. +3. **Update motorZPP** to import from `common/step.ts` and use `Step`, `StepHandler`. +4. **Implement sensoryZPP** using the same `Step` and `StepHandler`. + +### Migration order + +1. Create `common/step.ts` with generic types. +2. Refactor `motorZPP.ts` to use shared types (no behavior change). +3. Implement `sensoryZPP.ts` with step-based architecture. +4. Add `sensoryZPPErrors.ts` and translation keys. +5. Migrate `sensoryZPP.spec.ts` to cover step chain and `sensoryZPPSteps` generator. + +### 5.4 Generator for step-by-step execution + +Mirror `motorZPPSteps`: + +```typescript +export function* sensoryZPPSteps( + side: ExamSide, + deepAnalPressure: BinaryObservation, +): Generator> { + const initialState = getInitialState(side, deepAnalPressure); + let step = checkIfSensoryZPPIsApplicable(initialState); + yield step; + while (step.next) { + step = step.next(step.state); + yield step; + } +} +``` + +--- + +## 7. Translation Keys (to add to `en.ts`) + +```typescript +// Sensory ZPP +sensoryZPPCheckIfSensoryZPPIsApplicableDescription: 'Check if Deep Anal Pressure and S4-5 allow Sensory ZPP calculation.', +sensoryZPPCheckIfSensoryZPPIsApplicableYesAction: 'DAP is "Yes". Sensory ZPP is "NA".', +sensoryZPPCheckIfSensoryZPPIsApplicableS4_5PreservedAction: 'S4-5 has preserved sensation. Sensory ZPP is "NA".', +sensoryZPPCheckIfSensoryZPPIsApplicableProceedAction: 'DAP is "No" or "NT" and S4-5 sensation is absent. Proceed to evaluate sacral level.', +sensoryZPPCheckSacralLevelDescription: 'Evaluate S4-5 sensory values.', +sensoryZPPCheckSacralLevelAddNAAction: 'Add "NA" to Sensory ZPP based on DAP and sacral result.', +sensoryZPPCheckSacralLevelNoNAAction: 'Do not add "NA". Proceed to iterate levels.', +sensoryZPPGetTopAndBottomLevelsForCheckDescription: 'Set search range from S3 to C1.', +sensoryZPPGetTopAndBottomLevelsForCheckRangeAction: 'Range: {{top}} (top) to {{bottom}} (bottom).', +sensoryZPPCheckLevelDescription: 'Check sensory function at {{levelName}} (LT: {{lightTouch}}, PP: {{pinPrick}}).', +sensoryZPPCheckLevelAddLevelAction: 'Add {{levelName}} to Sensory ZPP.', +sensoryZPPCheckLevelContinueAction: 'Continue to next level.', +sensoryZPPCheckLevelStopAction: 'Sensory function boundary found. Stop iteration.', +sensoryZPPCheckLevelReachedC1Action: 'Reached C1. Add C1 and complete.', +sensoryZPPSortSensoryZPPDescription: 'Sort Sensory ZPP.', +sensoryZPPSortSensoryZPPEnsureNAIsPlacedFirstAction: 'Ensure "NA" is placed first.', +``` + +--- + +## 8. Algorithm Fidelity + +This architecture preserves the existing `determineSensoryZPP` and `checkLevelForSensoryZPP` behavior: + +- Same conditions for returning `NA`. +- Same iteration order (S3 down to C1). +- Same `checkLevelForSensoryZPP` logic (continue, level, variable). +- Same sort order (NA first, then levels by index). + +No algorithm behavior is modified; only the control flow is expressed as an explicit step chain. diff --git a/src/classification/common/step.ts b/src/classification/common/step.ts new file mode 100644 index 0000000..ba68332 --- /dev/null +++ b/src/classification/common/step.ts @@ -0,0 +1,35 @@ +import { Translation } from '../common'; + +export type StepDescription = { + key: Translation; + params?: { [key: string]: string }; +}; + +export type StepAction = { + key: Translation; + params?: { [key: string]: string }; +}; + +export type Step = { + description: StepDescription; + actions: StepAction[]; + next: StepHandler | null; + state: S; +}; + +export type StepHandler = (state: S) => Step; + +export function createStep( + description: Step['description'], + actions: Step['actions'], + state: S, + updates: Partial, + next: Step['next'], +): Step { + return { + description, + actions, + state: { ...state, ...updates }, + next, + }; +} diff --git a/src/classification/zoneOfPartialPreservation/README.md b/src/classification/zoneOfPartialPreservation/README.md new file mode 100644 index 0000000..05d4b37 --- /dev/null +++ b/src/classification/zoneOfPartialPreservation/README.md @@ -0,0 +1,413 @@ +# Zone of Partial Preservation (ZPP) + +This module computes the **Sensory ZPP** and **Motor ZPP** for the International Standards for Neurological Classification of Spinal Cord Injury (ISNCSCI). Both algorithms use a step-based, chain-of-command pattern so clinicians can follow the logic and see where each value is generated. + +--- + +## Overview + +The **Zone of Partial Preservation (ZPP)** describes the dermatomes and myotomes with partial preservation of sensation or motor function caudal to the neurological level of injury (NLI). When sacral sparing is absent or not testable, ZPP identifies the most caudal segments with any preserved function. + +| ZPP Type | What it reports | +| --------------- | -------------------------------------------------------------------------------------------------- | +| **Sensory ZPP** | The most caudal dermatomes with preserved light touch or pin prick sensation | +| **Motor ZPP** | The most caudal myotomes with preserved motor function (including non-key muscles when applicable) | + +When ZPP does not apply (e.g., sacral sparing is present), the result is **NA** (Not Applicable). + +--- + +## Prerequisites + +### Sensory ZPP + +| Input | Description | +| ------------------ | ---------------------------------------------------------------------------------- | +| `side` | Exam data for one side: light touch and pin prick values at each key sensory point | +| `deepAnalPressure` | DAP: `Yes`, `No`, or `NT` | + +### Motor ZPP + +| Input | Description | +| -------------------------- | ---------------------------------------------------------------- | +| `side` | Exam data for one side: light touch, pin prick, and motor grades | +| `voluntaryAnalContraction` | VAC: `Yes`, `No`, or `NT` | +| `ais` | AIS grade (A, B, C, C\*, D, E) | +| `motorLevel` | Comma-separated motor levels (e.g. `"C5,C6,C7"`) | + +--- + +# Sensory ZPP + +## Algorithm Summary + +Sensory ZPP is calculated when **Deep Anal Pressure (DAP)** is `No` or `NT` and **S4-5** sensation is absent. The algorithm iterates from **S3** up to **C1**, checking each dermatome for preserved light touch or pin prick until it finds the caudal boundary of preserved sensation. + +**Early exits:** Sensory ZPP is `NA` when: + +- DAP is `Yes` (sacral sparing present) +- S4-5 has preserved sensation (either light touch or pin prick is not absent) + +## Step Index + +| Step | Name | Purpose | +| ---- | ------------------------------- | --------------------------------------------------------------- | +| 1 | `checkIfSensoryZPPIsApplicable` | Determine if Sensory ZPP applies or return NA immediately | +| 2 | `checkSacralLevel` | Evaluate S4-5; optionally add NA based on DAP and sacral result | +| 3 | `getTopAndBottomLevelsForCheck` | Set search range from S3 to C1 | +| 4 | `checkLevel` | For each level, check sensory function; add level or stop | +| 5 | `sortSensoryZPP` | Sort results with NA first (final step) | + +## Step-by-Step Explanations + +### Step 1: checkIfSensoryZPPIsApplicable + +**Purpose:** Determine whether Sensory ZPP applies. If sacral sparing is present, Sensory ZPP is NA and we stop. + +**Inputs:** Deep Anal Pressure (DAP), S4-5 light touch and pin prick values. + +**Logic:** + +- If DAP is **Yes** → Sacral sparing is present. Sensory ZPP = **NA**. Stop. +- If S4-5 has **preserved sensation** (neither light touch nor pin prick is absent) → Sensory ZPP = **NA**. Stop. +- Otherwise (DAP is No or NT, and S4-5 sensation is absent) → Proceed to evaluate sacral level. + +**Outputs:** `zpp` is either `['NA']` (stop) or `[]` (proceed). `variable` is initialized to `false`. + +**Next step:** `checkSacralLevel` if proceeding; otherwise `null` (done). + +**Clinical note:** Per ISNCSCI, Sensory ZPP is only reported when sacral sparing is absent. DAP "Yes" or preserved S4-5 sensation indicates sacral sparing. + +--- + +### Step 2: checkSacralLevel + +**Purpose:** Evaluate S4-5 sensory values and decide whether to add NA to the ZPP list before iterating through higher levels. + +**Inputs:** Exam side (S4-5 light touch and pin prick), DAP, and `variable` flag from state. + +**Logic:** + +- Evaluate S4-5 using the same sensory-check rules as other levels. +- Add **NA** to ZPP when: + - DAP is **NT** (not testable), or + - DAP is **No** and S4-5 indicates absent or partial sensation (sacral result suggests NA). +- The sacral check does **not** propagate its `variable` result to the S3→C1 iteration; `variable` remains `false` when that loop starts (matching original behavior). + +**Outputs:** `zpp` may include `'NA'`; `variable` is unchanged (remains from prior step). + +**Next step:** `getTopAndBottomLevelsForCheck`. + +**Clinical note:** The NA in Sensory ZPP can indicate that sacral sensation could not be fully assessed (DAP NT) or that S4-5 was partially absent. + +--- + +### Step 3: getTopAndBottomLevelsForCheck + +**Purpose:** Define the iteration range and prepare the level chain. We will examine levels from S3 (top) down to C1 (bottom). + +**Inputs:** Exam side (to build the level chain). + +**Logic:** + +- Build a linked chain of sensory levels from **S3** down to **C1**. +- Set `topLevel` = S3, `bottomLevel` = C1. +- Set `currentLevel` = S3 (start of iteration). + +**Outputs:** `topLevel`, `bottomLevel`, `currentLevel` in state. + +**Next step:** `checkLevel`. + +**Clinical note:** The algorithm always iterates from S3 toward C1. C1 is the most rostral key sensory point and is added when the iteration completes. + +--- + +### Step 4: checkLevel + +**Purpose:** For the current dermatome, check whether sensory function (light touch and pin prick) is preserved. Add the level to ZPP when indicated; continue to the next level or stop when the boundary is found. + +**Inputs:** `currentLevel` (name, light touch, pin prick), exam side, `variable` flag, `zpp` list. + +**Logic:** + +- **If current level is C1:** Add C1 to ZPP (per ISNCSCI, C1 is always included when we reach it). Proceed to sort. +- **Otherwise:** Run the sensory check for this level: + - If **both** light touch and pin prick are **absent** (0) → Continue to next level (no add). + - If **either** cannot be absent (e.g. 2, 1, NT) → Add level to ZPP and **stop** (sensory boundary found). + - If **either** is NT or has variable indicator → Add level (with \* if variable) and continue. +- Advance `currentLevel` to the next level down (e.g. S3 → S2 → S1 → … → C2 → C1). + +**Outputs:** `zpp` (levels added as we go), `variable` (updated when variable values are found), `currentLevel` (advanced or set to null when done). + +**Next step:** `checkLevel` again if more levels to check; otherwise `sortSensoryZPP`. + +**Clinical note:** The asterisk (\*) indicates variable or non-normal sensation. The algorithm stops when it finds a level where sensation is clearly preserved (not absent). + +--- + +### Step 5: sortSensoryZPP + +**Purpose:** Sort the Sensory ZPP list so that NA (if present) appears first, followed by levels in anatomical order (rostral to caudal). + +**Inputs:** `zpp` list from previous steps. + +**Logic:** + +- Sort so **NA** is first (if it was added). +- Then sort remaining levels by anatomical order (C1, C2, C3, C4, … L5, S1, S2, S3). + +**Outputs:** Final `zpp` list. + +**Next step:** `null` (algorithm complete). + +**Clinical note:** The final output is a comma-separated string, e.g. `"NA,C1,C2"` or `"C1,C2"`. + +--- + +## Sensory ZPP Flow (Text Diagram) + +``` +checkIfSensoryZPPIsApplicable + ├─ DAP Yes or S4-5 preserved → zpp = ['NA'], STOP + └─ DAP No/NT and S4-5 absent → checkSacralLevel + +checkSacralLevel + └─ (add NA if indicated) → getTopAndBottomLevelsForCheck + +getTopAndBottomLevelsForCheck + └─ range S3→C1, currentLevel=S3 → checkLevel + +checkLevel (iterates S3 down to C1) + ├─ C1 reached → add C1 → sortSensoryZPP + ├─ Both LT and PP absent → continue to next level + ├─ Either preserved → add level, STOP → sortSensoryZPP + └─ Variable (NT, 0*) → add level with *, continue + +sortSensoryZPP + └─ NA first, then levels by order → DONE +``` + +--- + +# Motor ZPP + +## Algorithm Summary + +Motor ZPP is calculated when **Voluntary Anal Contraction (VAC)** is `No` or `NT`. When VAC is `Yes`, Motor ZPP is **NA**. The algorithm uses the **motor levels** to define a search range from the lowest motor level up to the highest. It iterates from **bottom to top** within that range, checking each level for motor or sensory function. For **AIS C or C\***, the lowest non-key muscle with motor function may be included in Motor ZPP. + +**Early exits:** Motor ZPP is `NA` when VAC is `Yes`. + +## Step Index + +| Step | Name | Purpose | +| ---- | ---------------------------------------- | ------------------------------------------------------- | +| 1 | `checkIfMotorZPPIsApplicable` | Check VAC; return NA if Yes, else proceed | +| 2 | `checkLowerNonKeyMuscle` | Determine if non-key muscle affects AIS (for AIS C/C\*) | +| 3 | `getTopAndBottomLevelsForCheck` | Define search range from motor levels | +| 4 | `checkLevel` | Branch to motor or sensory check for current level | +| 4a | `checkForMotorFunction` | Evaluate motor grade; add level or continue | +| 4b | `checkForSensoryFunction` | Evaluate sensory when level has no motor key muscle | +| 5 | `addLowerNonKeyMuscleToMotorZPPIfNeeded` | Add non-key muscle to ZPP when it affects AIS | +| 6 | `sortMotorZPP` | Sort results with NA first (final step) | + +## Step-by-Step Explanations + +### Step 1: checkIfMotorZPPIsApplicable + +**Purpose:** Determine whether Motor ZPP applies based on Voluntary Anal Contraction (VAC). + +**Inputs:** `voluntaryAnalContraction` (Yes, No, or NT). + +**Logic:** + +- If VAC is **Yes** → Sacral motor function is preserved. Motor ZPP = **NA**. Stop. +- If VAC is **NT** → Motor ZPP = **NA** for the final result, but we still proceed to determine the top and bottom levels (for consistency and non-key muscle logic). +- If VAC is **No** → Leave ZPP empty and proceed. + +**Outputs:** `zpp` is `['NA']` (VAC Yes or NT) or `[]` (VAC No). + +**Next step:** `checkLowerNonKeyMuscle` if continuing; otherwise `null` (done). + +**Clinical note:** Per ISNCSCI, Motor ZPP is NA when VAC is present. VAC "Yes" indicates preserved sacral motor function. + +--- + +### Step 2: checkLowerNonKeyMuscle + +**Purpose:** For AIS C or C\*, the lowest non-key muscle with motor function can influence the AIS grade. Set a flag so the algorithm considers it when building Motor ZPP. + +**Inputs:** AIS grade, `side.lowestNonKeyMuscleWithMotorFunction`. + +**Logic:** + +- If AIS is **C** or **C\*** and there is a **lowest non-key muscle with motor function** → Set `testNonKeyMuscle = true`. +- Otherwise → Set `testNonKeyMuscle = false`. + +**Outputs:** `testNonKeyMuscle` in state. + +**Next step:** `getTopAndBottomLevelsForCheck`. + +**Clinical note:** AIS C implies sensory function at S4-5. The non-key muscle (e.g. hip flexors) may extend the zone of partial preservation and affect the classification. + +--- + +### Step 3: getTopAndBottomLevelsForCheck + +**Purpose:** Using the motor levels, define the top and bottom of the search range. Build the level chain and find key reference levels. + +**Inputs:** `motorLevel` (comma-separated), exam side. + +**Logic:** + +- **Top** = first (highest) motor level (e.g. C5 if motor levels are C5,C6,C7). +- **Bottom** = S1 if no motor levels below S1; otherwise the lowest motor level (e.g. S2, S3, or INT). +- Build levels from C1 to bottom; link them; set `currentLevel = bottomLevel`. +- Find `firstLevelWithStar` (first level with \* in LT, PP, or motor). +- Find `lastLevelWithConsecutiveNormalValues` (last level before first non-normal value). +- Identify `nonKeyMuscle` level if applicable. + +**Outputs:** `topLevel`, `bottomLevel`, `currentLevel`, `firstLevelWithStar`, `lastLevelWithConsecutiveNormalValues`, `nonKeyMuscle`. + +**Next step:** `checkLevel`. + +**Clinical note:** The algorithm iterates from bottom to top. Levels below S1 (S2, S3) are only included when motor function exists at those levels. + +--- + +### Step 4: checkLevel + +**Purpose:** Dispatch to the appropriate check based on whether the current level has a key motor muscle. + +**Inputs:** `currentLevel` (with motor grade if it's a motor level). + +**Logic:** + +- If the level has a **motor** value (it's a key motor level) → `checkForMotorFunction`. +- Otherwise (sensory-only level, e.g. S2, S3) → `checkForSensoryFunction`. + +**Outputs:** Delegates to the branch; updates `zpp`, `currentLevel`, and possibly `addNonKeyMuscle`. + +**Next step:** From the branch (either `checkLevel` again, or `addLowerNonKeyMuscleToMotorZPPIfNeeded` when at top). + +--- + +### Step 4a: checkForMotorFunction + +**Purpose:** Evaluate motor function at the current key muscle level. Add the level to Motor ZPP when motor function is found; apply non-key muscle override when indicated. + +**Inputs:** `currentLevel` (name, motor grade), `firstLevelWithStar`, `lastLevelWithConsecutiveNormalValues`, `testNonKeyMuscle`, `nonKeyMuscle`. + +**Logic:** + +- **Case 1 – Normal motor function (grades 1–5, NT**, 0**):** Add level to ZPP (with \* if in star range). **Stop** iterating (we found the motor boundary). Non-key muscle may override. +- **Case 2 – NT or 0\*:** Add level to ZPP (with \* if applicable). **Continue** to next level toward top. Non-key muscle may override. +- **Case 3 – At top of range with no function:** Stop. Proceed to non-key muscle step. +- **Case 4 – No function, not at top:** Continue to next level. + +**Non-key muscle override:** If `testNonKeyMuscle` is true and the non-key muscle is >3 levels below the current level, the non-key muscle overrides: we do not add the current level and set `addNonKeyMuscle = true`. + +**Outputs:** `zpp`, `currentLevel` (moves to previous/next level), `addNonKeyMuscle`. + +**Next step:** `addLowerNonKeyMuscleToMotorZPPIfNeeded` when at top; otherwise `checkLevel`. + +**Clinical note:** The asterisk (\*) indicates variable or non-normal function. Motor ZPP includes the most caudal level with any preserved motor function. + +--- + +### Step 4b: checkForSensoryFunction + +**Purpose:** For levels without a key motor muscle (e.g. S2, S3), check sensory function. If the level is within the motor levels and has sensory function, add it to Motor ZPP. + +**Inputs:** `currentLevel` (light touch, pin prick), `motorLevel`, `firstLevelWithStar`, `lastLevelWithConsecutiveNormalValues`, `nonKeyMuscle`. + +**Logic:** + +- If the level is **included in motor levels** and has sensory function: + - Check non-key muscle override. + - If not overridden → Add level to ZPP (with \* if in star range). +- If at top of range → Stop. +- If no sensory function → Continue to next level. + +**Outputs:** `zpp`, `currentLevel`, `addNonKeyMuscle`. + +**Next step:** `addLowerNonKeyMuscleToMotorZPPIfNeeded` when at top; otherwise `checkLevel`. + +**Clinical note:** For sensory-only levels (S2, S3), we use light touch and pin prick to determine if the level belongs in Motor ZPP when it's part of the motor level range. + +--- + +### Step 5: addLowerNonKeyMuscleToMotorZPPIfNeeded + +**Purpose:** When the non-key muscle affects AIS (AIS C/C\* and `addNonKeyMuscle` is true), add it to Motor ZPP if not already added. + +**Inputs:** `addNonKeyMuscle`, `nonKeyMuscleHasBeenAdded`, `nonKeyMuscle`. + +**Logic:** + +- If `addNonKeyMuscle` is true, `nonKeyMuscleHasBeenAdded` is false, and `nonKeyMuscle` exists → Add the non-key muscle level to ZPP. +- Otherwise → Do not add. + +**Outputs:** `zpp` (possibly updated). + +**Next step:** `sortMotorZPP`. + +**Clinical note:** The non-key muscle (e.g. hip flexors at L1-L2) extends the zone of partial preservation when it influences the AIS grade. + +--- + +### Step 6: sortMotorZPP + +**Purpose:** Sort the Motor ZPP list so that NA (if present) is first, followed by levels in anatomical order. + +**Inputs:** `zpp` list from previous steps. + +**Logic:** + +- Sort so **NA** is first (if present). +- Then sort remaining levels by anatomical order (S3, S2, S1, L5, … C2, C1). + +**Outputs:** Final `zpp` list. + +**Next step:** `null` (algorithm complete). + +**Clinical note:** The final output is a comma-separated string, e.g. `"NA,C7,C6"` or `"C6,C5,S1"`. + +--- + +## Motor ZPP Flow (Text Diagram) + +``` +checkIfMotorZPPIsApplicable + ├─ VAC Yes → zpp = ['NA'], STOP + └─ VAC No/NT → checkLowerNonKeyMuscle + +checkLowerNonKeyMuscle + └─ set testNonKeyMuscle (AIS C + non-key muscle) → getTopAndBottomLevelsForCheck + +getTopAndBottomLevelsForCheck + └─ top/bottom from motor levels, currentLevel=bottom → checkLevel + +checkLevel (iterates bottom to top) + ├─ Has motor? → checkForMotorFunction + │ ├─ Normal motor (1-5, NT**, 0**) → add level, STOP + │ ├─ NT or 0* → add level, continue + │ ├─ At top, no function → STOP + │ └─ No function → continue + └─ No motor (S2, S3)? → checkForSensoryFunction + ├─ In motor levels + sensory → add level, continue/stop + └─ No sensory → continue + +addLowerNonKeyMuscleToMotorZPPIfNeeded + └─ add non-key muscle if applicable → sortMotorZPP + +sortMotorZPP + └─ NA first, then levels by order → DONE +``` + +--- + +## References + +- **ISNCSCI:** International Standards for Neurological Classification of Spinal Cord Injury (revised 2019) +- **ASIA:** American Spinal Injury Association +- **Key terms:** Neurological level of injury (NLI), Zone of Partial Preservation (ZPP), Deep Anal Pressure (DAP), Voluntary Anal Contraction (VAC), ASIA Impairment Scale (AIS), key muscles, key sensory points, dermatomes, myotomes diff --git a/src/classification/zoneOfPartialPreservation/motorZPP.ts b/src/classification/zoneOfPartialPreservation/motorZPP.ts index 574c174..a84488a 100644 --- a/src/classification/zoneOfPartialPreservation/motorZPP.ts +++ b/src/classification/zoneOfPartialPreservation/motorZPP.ts @@ -7,6 +7,7 @@ import { SensoryLevels, } from '../../interfaces'; import { SideLevel, Translation } from '../common'; +import { createStep, Step, StepHandler } from '../common/step'; import { MotorZPPError, MOTOR_ZPP_ERROR_MESSAGES, @@ -67,14 +68,8 @@ export type State = { lastLevelWithConsecutiveNormalValues: SideLevel; }; -export type StepHandler = (state: State) => Step; - -export type Step = { - description: { key: Translation; params?: { [key: string]: string } }; - actions: { key: Translation; params?: { [key: string]: string } }[]; - next: StepHandler | null; - state: State; -}; +export type MotorZPPStepHandler = StepHandler; +export type MotorZPPStep = Step; /* *************************************** */ /* Support methods */ @@ -274,21 +269,6 @@ function buildMotorZPPLevelName(levelName: string, hasStar: boolean): string { return `${levelName}${hasStar ? '*' : ''}`; } -function createStep( - description: Step['description'], - actions: Step['actions'], - state: State, - updates: Partial, - next: Step['next'], -): Step { - return { - description, - actions, - state: { ...state, ...updates }, - next, - }; -} - /* *************************************** */ /* Motor ZPP calculation command methods */ /* *************************************** */ @@ -297,7 +277,7 @@ function createStep( * This is the sixth and final step when calculating the motor ZPP. * Sorts the ZPP results ensuring the `NA` value, if available, is at the beginning of the list. */ -export function sortMotorZPP(state: State): Step { +export function sortMotorZPP(state: State): MotorZPPStep { const zpp = [...state.zpp].sort((a, b) => { const aIndex = a === 'NA' @@ -330,7 +310,7 @@ export function sortMotorZPP(state: State): Step { * It adds the non-key muscle to ZPP, it has not been added already and if one is available. * It sets `sortMotorZPP` as the next and final step. */ -export function addLowerNonKeyMuscleToMotorZPPIfNeeded(state: State): Step { +export function addLowerNonKeyMuscleToMotorZPPIfNeeded(state: State): MotorZPPStep { const description: { key: Translation } = { key: 'motorZPPAddLowerNonKeyMuscleToMotorZPPIfNeededDescription', }; @@ -377,7 +357,7 @@ export function addLowerNonKeyMuscleToMotorZPPIfNeeded(state: State): Step { * Could throw the following error: * - state.currentLevel is null. A SideLevel value is required. */ -export function checkForSensoryFunction(state: State): Step { +export function checkForSensoryFunction(state: State): MotorZPPStep { if (!state.currentLevel) { throw new MotorZPPError( 'CHECK_FOR_SENSORY_FUNCTION_CURRENT_LEVEL_REQUIRED', @@ -415,6 +395,13 @@ export function checkForSensoryFunction(state: State): Step { key: override ? 'motorZPPCheckForSensoryFunctionLevelIncludedButOverriddenByNonKeyMuscleAction' : 'motorZPPCheckForSensoryFunctionAddLevelAndContinueAction', + // Only add params when we actually add to ZPP (!override). The add action needs levelName + // to match the appended value (e.g. C5*). When overridden, we don't add anything. + ...(!override && { + params: { + levelName: buildMotorZPPLevelName(currentLevel.name, hasStar), + }, + }), }, ]; if (atTop) { @@ -475,7 +462,7 @@ export function checkForSensoryFunction(state: State): Step { * - state.currentLevel is null. A SideLevel value is required. * - state.currentLevel.motor is null. */ -export function checkForMotorFunction(state: State): Step { +export function checkForMotorFunction(state: State): MotorZPPStep { if (!state.currentLevel) { throw new MotorZPPError( 'CHECK_FOR_MOTOR_FUNCTION_CURRENT_LEVEL_REQUIRED', @@ -535,7 +522,14 @@ export function checkForMotorFunction(state: State): Step { } return createStep( description, - [{ key: 'motorZPPCheckForMotorFunctionAddLevelAndStopAction' }], + [ + { + key: 'motorZPPCheckForMotorFunctionAddLevelAndStopAction', + params: { + levelName: buildMotorZPPLevelName(currentLevel.name, hasStar), + }, + }, + ], state, addLevelStateUpdates(buildMotorZPPLevelName(currentLevel.name, hasStar)), nextWhenAtTop, @@ -574,6 +568,9 @@ export function checkForMotorFunction(state: State): Step { [ { key: 'motorZPPCheckForMotorFunctionAddLevelWithNormalFunctionAndContinue', + params: { + levelName: buildMotorZPPLevelName(currentLevel.name, hasStar), + }, }, ...rangeActions, ], @@ -616,7 +613,7 @@ export function checkForMotorFunction(state: State): Step { * This is the fourth step when calculating the Motor ZPP. * Checks if it is a sensory or motor level. It then calls either `checkForMotorFunction` or `checkForSensoryFunction`. */ -export function checkLevel(state: State): Step { +export function checkLevel(state: State): MotorZPPStep { return state.currentLevel?.motor ? checkForMotorFunction(state) : checkForSensoryFunction(state); @@ -629,7 +626,7 @@ export function checkLevel(state: State): Step { * It also builds a chain of `SideLevels` with only the levels that need testing. * It sets `currentLevel = bottom` and a reference to `nonKeyMuscle` if one was specified. */ -export function getTopAndBottomLevelsForCheck(state: State): Step { +export function getTopAndBottomLevelsForCheck(state: State): MotorZPPStep { const motorLevels = state.motorLevel .replace(PATTERNS.stripAsterisk, '') .split(','); @@ -694,7 +691,7 @@ export function getTopAndBottomLevelsForCheck(state: State): Step { * The flag will be used in the next steps to let the algorithm know if the Motor ZPP levels detected need to be tested against the non-key muscle. * An AIS C or C* implies that there is sensory function at S4-5 and that the lowest non-key muscle could have influenced the AIS calculation. */ -export function checkLowerNonKeyMuscle(state: State): Step { +export function checkLowerNonKeyMuscle(state: State): MotorZPPStep { // AIS C or C* implies that there is sensory function at S4-5 and that the lowest non-key muscle could have influenced the AIS calculation. const testNonKeyMuscle = state.side.lowestNonKeyMuscleWithMotorFunction !== null && @@ -725,7 +722,7 @@ export function checkLowerNonKeyMuscle(state: State): Step { * If the VAC is 'NT', we add 'NA' to the Motor ZPP and continue to check for the presence of a non-key muscle. * If the VAC is 'No', we leave the Motor ZPP empty and continue to check for the presence of a non-key muscle. */ -export function checkIfMotorZPPIsApplicable(state: State): Step { +export function checkIfMotorZPPIsApplicable(state: State): MotorZPPStep { const description: { key: Translation } = { key: 'motorZPPCheckIfMotorZPPIsApplicableDescription', }; @@ -826,7 +823,7 @@ export function* motorZPPSteps( voluntaryAnalContraction: BinaryObservation, ais: string, motorLevel: string, -): Generator { +): Generator { const initialState = getInitialState( side, voluntaryAnalContraction, diff --git a/src/classification/zoneOfPartialPreservation/sensoryZPP.spec.ts b/src/classification/zoneOfPartialPreservation/sensoryZPP.spec.ts index 9c05eb5..1c183ea 100644 --- a/src/classification/zoneOfPartialPreservation/sensoryZPP.spec.ts +++ b/src/classification/zoneOfPartialPreservation/sensoryZPP.spec.ts @@ -1,13 +1,22 @@ -import { checkLevelForSensoryZPP, determineSensoryZPP } from "./sensoryZPP" -import { BinaryObservation, ExamSide, SensoryPointValue } from "../../interfaces"; -import { newNormalSide } from "../commonSpec"; +import { + checkIfSensoryZPPIsApplicable, + checkLevel, + checkLevelForSensoryZPP, + checkSacralLevel, + determineSensoryZPP, + getInitialState, + getTopAndBottomLevelsForCheck, + sensoryZPPSteps, + sortSensoryZPP, + SensoryZPPError, +} from './sensoryZPP'; +import { BinaryObservation, ExamSide, SensoryPointValue } from '../../interfaces'; +import { newNormalSide, propagateSensoryValueFrom } from '../commonSpec'; let side: ExamSide = newNormalSide(); -// 400 tests + 2 verification tests describe('sensoryZPP', () => { - // 300 tests + 1 verification test - describe('zpp with', () => { + describe('determineSensoryZPP with variable DAP and PP/LT at S4_5', () => { const allValues: {deepAnalPressure: BinaryObservation ; pinPrick: SensoryPointValue; lightTouch: SensoryPointValue}[] = []; beforeEach(() => { @@ -110,11 +119,38 @@ describe('sensoryZPP', () => { const hashSet = new Set(allValues.map(v => v.deepAnalPressure + v.pinPrick + v.lightTouch)); expect(allValues.length).toBe(300); expect(hashSet.size).toBe(300); - }) - }) + }); + + it('produces correct level output when DAP=No and S4_5 absent', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + propagateSensoryValueFrom(side, 'S3', '0'); + + const result = determineSensoryZPP(side, 'No'); + expect(result).not.toContain('NA'); + expect(result).toContain('S2'); + }); + + it('does not propagate sacral variable to S3 and above (S4_5=0*/0*, S3=NT)', () => { + // S4_5=0*/0* would set variable=true in checkLevelForSensoryZPP, but original + // does NOT propagate that to the S3→C1 loop. S3 with NT should get S3 (no asterisk). + propagateSensoryValueFrom(side, 'S2', '0'); + side.lightTouch.S4_5 = '0*'; + side.pinPrick.S4_5 = '0*'; + side.lightTouch.S3 = 'NT'; + side.pinPrick.S3 = '0'; + + const result = determineSensoryZPP(side, 'No'); + expect(result).toContain('NA'); + expect(result).toContain('S3'); + expect(result).not.toContain('S3*'); + }); + }); - // 100 tests + 1 verification test describe('checkLevelForSensoryZPP', () => { + beforeEach(() => { + side = newNormalSide(); + }); const allValues: {pinPrick: SensoryPointValue; lightTouch: SensoryPointValue}[] = []; afterEach(() => { allValues.push({pinPrick: side.pinPrick.S3, lightTouch: side.lightTouch.S3}); @@ -256,6 +292,358 @@ describe('sensoryZPP', () => { const hashSet = new Set(allValues.map(v => v.pinPrick + v.lightTouch)); expect(allValues.length).toBe(100); expect(hashSet.size).toBe(100); - }) - }) + }); + + it('throws SensoryZPPError when level is C1', () => { + expect(() => checkLevelForSensoryZPP(side, 'C1', false)).toThrow(SensoryZPPError); + expect(() => checkLevelForSensoryZPP(side, 'C1', false)).toThrow( + 'checkLevelForSensoryZPP :: invalid argument level: C1', + ); + }); + + it('adds asterisk to level when variable is true and sensory boundary found', () => { + side.pinPrick.S3 = '1'; + side.lightTouch.S3 = '0'; + const result = checkLevelForSensoryZPP(side, 'S3', true); + expect(result.continue).toBe(false); + expect(result.level).toBe('S3*'); + }); + }); + + /* *************************************** */ + /* checkIfSensoryZPPIsApplicable tests */ + /* *************************************** */ + + describe('checkIfSensoryZPPIsApplicable', () => { + beforeEach(() => { + side = newNormalSide(); + }); + + describe('DAP = Yes', () => { + it('adds NA to Sensory ZPP and stops', () => { + const state = getInitialState(side, 'Yes'); + const step = checkIfSensoryZPPIsApplicable(state); + + expect(step.state.zpp).toEqual(['NA']); + expect(step.next).toBeNull(); + expect(step.description.key).toBe('sensoryZPPCheckIfSensoryZPPIsApplicableDescription'); + expect(step.actions.length).toEqual(1); + expect(step.actions[0].key).toEqual('sensoryZPPCheckIfSensoryZPPIsApplicableYesAction'); + }); + }); + + describe('DAP = NT', () => { + it('leaves zpp empty and continues to checkSacralLevel', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + const state = getInitialState(side, 'NT'); + + const step = checkIfSensoryZPPIsApplicable(state); + + expect(step.state.zpp).toEqual([]); + expect(step.state.variable).toBe(false); + expect(step.next).toBe(checkSacralLevel); + expect(step.actions[0].key).toEqual('sensoryZPPCheckIfSensoryZPPIsApplicableProceedAction'); + }); + }); + + describe('DAP = No', () => { + it('leaves zpp empty and continues to checkSacralLevel when S4_5 absent', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + const state = getInitialState(side, 'No'); + + const step = checkIfSensoryZPPIsApplicable(state); + + expect(step.state.zpp).toEqual([]); + expect(step.state.variable).toBe(false); + expect(step.next).toBe(checkSacralLevel); + expect(step.actions[0].key).toEqual('sensoryZPPCheckIfSensoryZPPIsApplicableProceedAction'); + }); + + it('adds NA and stops when S4_5 has preserved sensation', () => { + side.lightTouch.S4_5 = '2'; + side.pinPrick.S4_5 = '0'; + const state = getInitialState(side, 'No'); + + const step = checkIfSensoryZPPIsApplicable(state); + + expect(step.state.zpp).toEqual(['NA']); + expect(step.next).toBeNull(); + expect(step.actions[0].key).toEqual('sensoryZPPCheckIfSensoryZPPIsApplicableS4_5PreservedAction'); + }); + }); + }); + + /* *************************************** */ + /* checkSacralLevel tests */ + /* *************************************** */ + + describe('checkSacralLevel', () => { + beforeEach(() => { + side = newNormalSide(); + }); + + describe('DAP = NT', () => { + it('adds NA to zpp and continues to getTopAndBottomLevelsForCheck', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + const state = getInitialState(side, 'NT'); + state.zpp = []; + state.variable = false; + + const step = checkSacralLevel(state); + + expect(step.state.zpp).toEqual(['NA']); + expect(step.next).toBe(getTopAndBottomLevelsForCheck); + expect(step.actions[0].key).toEqual('sensoryZPPCheckSacralLevelAddNAAction'); + }); + }); + + describe('DAP = No with sacral absent', () => { + it('adds NA to zpp when sacral result has level', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = 'NT'; + const state = getInitialState(side, 'No'); + state.zpp = []; + state.variable = false; + + const step = checkSacralLevel(state); + + expect(step.state.zpp).toEqual(['NA']); + expect(step.actions[0].key).toEqual('sensoryZPPCheckSacralLevelAddNAAction'); + }); + }); + + describe('DAP = No with sacral fully absent', () => { + it('does not add NA and continues to getTopAndBottomLevelsForCheck', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + const state = getInitialState(side, 'No'); + state.zpp = []; + state.variable = false; + + const step = checkSacralLevel(state); + + expect(step.state.zpp).toEqual([]); + expect(step.next).toBe(getTopAndBottomLevelsForCheck); + expect(step.actions[0].key).toEqual('sensoryZPPCheckSacralLevelNoNAAction'); + }); + }); + }); + + /* *************************************** */ + /* getTopAndBottomLevelsForCheck tests */ + /* *************************************** */ + + describe('getTopAndBottomLevelsForCheck', () => { + beforeEach(() => { + side = newNormalSide(); + }); + + it('sets topLevel=S3, bottomLevel=C1, currentLevel=S3 and chains to checkLevel', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + const state = getInitialState(side, 'No'); + state.zpp = []; + state.variable = false; + + const step = getTopAndBottomLevelsForCheck(state); + + expect(step.state.topLevel?.name).toBe('S3'); + expect(step.state.bottomLevel?.name).toBe('C1'); + expect(step.state.currentLevel?.name).toBe('S3'); + expect(step.next).toBe(checkLevel); + expect(step.description.key).toBe('sensoryZPPGetTopAndBottomLevelsForCheckDescription'); + expect(step.actions[0]).toEqual({ + key: 'sensoryZPPGetTopAndBottomLevelsForCheckRangeAction', + params: { top: 'S3', bottom: 'C1' }, + }); + }); + }); + + /* *************************************** */ + /* checkLevel tests */ + /* *************************************** */ + + describe('checkLevel', () => { + beforeEach(() => { + side = newNormalSide(); + }); + + it('throws SensoryZPPError when currentLevel is null', () => { + const state = getInitialState(side, 'No'); + state.currentLevel = null; + + expect(() => checkLevel(state)).toThrow(SensoryZPPError); + expect(() => checkLevel(state)).toThrow( + 'checkLevel :: state.currentLevel is required.', + ); + }); + + it('adds C1 and chains to sortSensoryZPP when currentLevel is C1', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + const state = getInitialState(side, 'No'); + const { topLevel, bottomLevel } = getTopAndBottomLevelsForCheck(state).state; + state.topLevel = topLevel; + state.bottomLevel = bottomLevel; + state.currentLevel = bottomLevel; + state.zpp = []; + state.variable = false; + + const step = checkLevel(state); + + expect(step.state.zpp).toEqual(['C1']); + expect(step.state.currentLevel).toBeNull(); + expect(step.next).toBe(sortSensoryZPP); + expect(step.actions[0].key).toBe('sensoryZPPCheckLevelReachedC1Action'); + }); + + it('adds level and continues when result.continue and result.level', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + side.lightTouch.S3 = 'NT'; + side.pinPrick.S3 = '0'; + const state = getInitialState(side, 'No'); + const stepResult = getTopAndBottomLevelsForCheck(state); + state.topLevel = stepResult.state.topLevel; + state.bottomLevel = stepResult.state.bottomLevel; + state.currentLevel = stepResult.state.currentLevel; + state.zpp = []; + state.variable = false; + + const step = checkLevel(state); + + expect(step.state.zpp).toEqual(['S3']); + expect(step.state.currentLevel?.name).toBe('S2'); + expect(step.next).toBe(checkLevel); + expect(step.actions[0].key).toBe('sensoryZPPCheckLevelAddLevelAction'); + expect(step.actions[1].key).toBe('sensoryZPPCheckLevelContinueAction'); + }); + + it('adds level and stops when !result.continue', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + side.lightTouch.S3 = '1'; + side.pinPrick.S3 = '0'; + const state = getInitialState(side, 'No'); + const stepResult = getTopAndBottomLevelsForCheck(state); + state.topLevel = stepResult.state.topLevel; + state.bottomLevel = stepResult.state.bottomLevel; + state.currentLevel = stepResult.state.currentLevel; + state.zpp = []; + state.variable = false; + + const step = checkLevel(state); + + expect(step.state.zpp).toEqual(['S3']); + expect(step.state.currentLevel).toBeNull(); + expect(step.next).toBe(sortSensoryZPP); + expect(step.actions[0].key).toBe('sensoryZPPCheckLevelAddLevelAction'); + expect(step.actions[1].key).toBe('sensoryZPPCheckLevelStopAction'); + }); + + it('continues without adding level when both LT and PP absent', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + side.lightTouch.S3 = '0'; + side.pinPrick.S3 = '0'; + const state = getInitialState(side, 'No'); + const stepResult = getTopAndBottomLevelsForCheck(state); + state.topLevel = stepResult.state.topLevel; + state.bottomLevel = stepResult.state.bottomLevel; + state.currentLevel = stepResult.state.currentLevel; + state.zpp = []; + state.variable = false; + + const step = checkLevel(state); + + expect(step.state.zpp).toEqual([]); + expect(step.state.currentLevel?.name).toBe('S2'); + expect(step.next).toBe(checkLevel); + expect(step.actions).toHaveLength(1); + expect(step.actions[0].key).toBe('sensoryZPPCheckLevelContinueAction'); + }); + }); + + /* *************************************** */ + /* sortSensoryZPP tests */ + /* *************************************** */ + + describe('sortSensoryZPP', () => { + it('sorts zpp with NA first and stops (next is null)', () => { + const state = getInitialState(side, 'No'); + state.zpp = ['S3', 'NA', 'S2']; + + const step = sortSensoryZPP(state); + + expect(step.state.zpp).toEqual(['NA', 'S2', 'S3']); + expect(step.next).toBeNull(); + expect(step.description.key).toBe('sensoryZPPSortSensoryZPPDescription'); + expect(step.actions[0].key).toBe('sensoryZPPSortSensoryZPPEnsureNAIsPlacedFirstAction'); + }); + + it('sorts zpp by level index when no NA', () => { + const state = getInitialState(side, 'No'); + state.zpp = ['S1', 'S3', 'S2']; + + const step = sortSensoryZPP(state); + + expect(step.state.zpp).toEqual(['S1', 'S2', 'S3']); + expect(step.next).toBeNull(); + }); + }); + + describe('sensoryZPPSteps', () => { + beforeEach(() => { + side = newNormalSide(); + }); + + it('yields at least one step', () => { + const steps = Array.from(sensoryZPPSteps(side, 'Yes')); + expect(steps.length).toBeGreaterThanOrEqual(1); + }); + + it('final step result matches determineSensoryZPP for same inputs', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + + const expected = determineSensoryZPP(side, 'No'); + const steps = Array.from(sensoryZPPSteps(side, 'No')); + const lastStep = steps[steps.length - 1]; + const actual = lastStep.state.zpp.join(','); + + expect(actual).toBe(expected); + }); + + it('DAP = Yes yields 1 step and stops (next is null)', () => { + const steps = Array.from(sensoryZPPSteps(side, 'Yes')); + expect(steps).toHaveLength(1); + expect(steps[0].next).toBeNull(); + expect(steps[0].state.zpp).toEqual(['NA']); + }); + + it('DAP = No yields multiple steps for full calculation', () => { + side.lightTouch.S4_5 = '0'; + side.pinPrick.S4_5 = '0'; + + const steps = Array.from(sensoryZPPSteps(side, 'No')); + expect(steps.length).toBeGreaterThan(1); + expect(steps[steps.length - 1].next).toBeNull(); + }); + + it('each step has description, actions, state, and next', () => { + const steps = Array.from(sensoryZPPSteps(side, 'Yes')); + for (const step of steps) { + expect(step).toHaveProperty('description'); + expect(step).toHaveProperty('actions'); + expect(step).toHaveProperty('state'); + expect(step).toHaveProperty('next'); + expect(step.description).toHaveProperty('key'); + expect(Array.isArray(step.actions)).toBe(true); + expect(step.state).toHaveProperty('zpp'); + } + }); + }); }) \ No newline at end of file diff --git a/src/classification/zoneOfPartialPreservation/sensoryZPP.ts b/src/classification/zoneOfPartialPreservation/sensoryZPP.ts index 07e5949..61e0393 100644 --- a/src/classification/zoneOfPartialPreservation/sensoryZPP.ts +++ b/src/classification/zoneOfPartialPreservation/sensoryZPP.ts @@ -1,70 +1,408 @@ -import { BinaryObservation, ExamSide, SensoryPointValue, SensoryLevels, SensoryLevel } from "../../interfaces"; -import { canBeAbsentSensory, CheckLevelResult } from "../common"; +import { + BinaryObservation, + ExamSide, + SensoryLevel, + SensoryLevels, + SensoryPointValue, +} from '../../interfaces'; +import { canBeAbsentSensory, CheckLevelResult, Translation } from '../common'; +import { createStep, Step, StepHandler } from '../common/step'; +import { SensoryZPPError } from './sensoryZPPErrors'; + +export { SensoryZPPError, SENSORY_ZPP_ERROR_MESSAGES } from './sensoryZPPErrors'; + +/* *************************************** */ +/* Constants */ +/* *************************************** */ + +const PATTERNS = { + singleAsterisk: /\*/g, +} as const; + +/* *************************************** */ +/* Types */ +/* *************************************** */ + +export type SensoryLevelNode = { + name: SensoryLevel; + lightTouch: SensoryPointValue; + pinPrick: SensoryPointValue; + index: number; + next: SensoryLevelNode | null; + previous: SensoryLevelNode | null; +}; + +export type State = { + side: ExamSide; + deepAnalPressure: BinaryObservation; + zpp: string[]; + variable: boolean; + topLevel: SensoryLevelNode | null; + bottomLevel: SensoryLevelNode | null; + currentLevel: SensoryLevelNode | null; +}; + +export type SensoryZPPStepHandler = StepHandler; +export type SensoryZPPStep = Step; + +/* *************************************** */ +/* Support methods */ +/* *************************************** */ const isAbsentSensory = (value: SensoryPointValue): boolean => value === '0'; -export const checkLevelForSensoryZPP = (side: ExamSide, level: SensoryLevel, variable: boolean): CheckLevelResult=> { +export const checkLevelForSensoryZPP = ( + side: ExamSide, + level: SensoryLevel, + variable: boolean, +): CheckLevelResult => { if (level === 'C1') { - throw `invalid argument level: ${level}`; + throw new SensoryZPPError('CHECK_LEVEL_C1_INVALID'); } const currentLevelPinPrickIsAbsent = isAbsentSensory(side.pinPrick[level]); - const currentLevelLightTouchIsAbsent = isAbsentSensory(side.lightTouch[level]); + const currentLevelLightTouchIsAbsent = isAbsentSensory( + side.lightTouch[level], + ); if (currentLevelPinPrickIsAbsent && currentLevelLightTouchIsAbsent) { - // TODO: remove hard coded variable - return {continue: true, variable}; + return { continue: true, variable }; + } + + if ( + !canBeAbsentSensory(side.pinPrick[level]) || + !canBeAbsentSensory(side.lightTouch[level]) + ) { + return { continue: false, level: level + (variable ? '*' : ''), variable }; + } + + const foundSomeNT = [side.pinPrick[level], side.lightTouch[level]].some( + (v) => ['NT', 'NT*'].includes(v), + ); + if (foundSomeNT) { + return { continue: true, level: level + (variable ? '*' : ''), variable }; + } + return { continue: true, level: level + '*', variable: variable || !foundSomeNT }; +}; + +function createSensoryLevelNode( + side: ExamSide, + level: SensoryLevel, + index: number, +): SensoryLevelNode { + const lightTouch = + level === 'C1' ? '2' : side.lightTouch[level]; + const pinPrick = + level === 'C1' ? '2' : side.pinPrick[level]; + return { + name: level, + lightTouch, + pinPrick, + index, + next: null, + previous: null, + }; +} + +/** Builds a linked chain of SensoryLevelNode from S3 down to C1. */ +function buildLevelChainFromS3ToC1( + side: ExamSide, +): { topLevel: SensoryLevelNode; bottomLevel: SensoryLevelNode } { + const s3Index = SensoryLevels.indexOf('S3'); + const levels: SensoryLevelNode[] = []; + for (let i = s3Index; i >= 0; i--) { + const levelName = SensoryLevels[i] as SensoryLevel; + levels.push(createSensoryLevelNode(side, levelName, i)); + } + for (let i = 0; i < levels.length - 1; i++) { + const curr = levels[i]; + const next = levels[i + 1]; + curr.next = next; + next.previous = curr; + } + return { + topLevel: levels[0], + bottomLevel: levels[levels.length - 1], + }; +} + +/* *************************************** */ +/* Sensory ZPP calculation step methods */ +/* *************************************** */ + +/* + * This is the fifth and final step when calculating the Sensory ZPP. + * Sorts the ZPP results ensuring the NA value, if available, is at the beginning of the list. + */ +export function sortSensoryZPP(state: State): SensoryZPPStep { + const zpp = [...state.zpp].sort((a, b) => { + const aIndex = + a === 'NA' + ? -1 + : SensoryLevels.indexOf( + a.replace(PATTERNS.singleAsterisk, '') as SensoryLevel, + ); + const bIndex = + b === 'NA' + ? -1 + : SensoryLevels.indexOf( + b.replace(PATTERNS.singleAsterisk, '') as SensoryLevel, + ); + return aIndex - bIndex; + }); + + return { + description: { key: 'sensoryZPPSortSensoryZPPDescription' }, + actions: [{ key: 'sensoryZPPSortSensoryZPPEnsureNAIsPlacedFirstAction' }], + state: { ...state, zpp }, + next: null, + }; +} + +/* + * This is the fourth step when calculating the Sensory ZPP. + * For each level from S3 down to C1, checks sensory function and adds level to ZPP when indicated. + * Continues to the next level or stops when the sensory boundary is found or C1 is reached. + */ +export function checkLevel(state: State): SensoryZPPStep { + if (!state.currentLevel) { + throw new SensoryZPPError('CURRENT_LEVEL_REQUIRED'); + } + + const currentLevel = state.currentLevel; + const description: { key: Translation; params: { [key: string]: string } } = { + key: 'sensoryZPPCheckLevelDescription', + params: { + levelName: currentLevel.name, + lightTouch: currentLevel.lightTouch, + pinPrick: currentLevel.pinPrick, + }, + }; + + if (currentLevel.name === 'C1') { + return createStep( + description, + [{ key: 'sensoryZPPCheckLevelReachedC1Action' }], + state, + { + zpp: [...state.zpp, currentLevel.name], + currentLevel: null, + }, + sortSensoryZPP, + ); } - if (!canBeAbsentSensory(side.pinPrick[level]) || !canBeAbsentSensory(side.lightTouch[level])) { - // TODO: remove hard coded variable - return {continue: false, level: level + (variable ? '*' : ''), variable}; - } else { - // TODO: remove hard coded variable - const foundSomeNT = [side.pinPrick[level],side.lightTouch[level]].some(v => ['NT', 'NT*'].includes(v)); - if (foundSomeNT) { - return {continue: true, level: level + (variable ? '*' : ''), variable}; - } else { - return {continue: true, level: level + '*', variable: variable || !foundSomeNT}; - } + const result = checkLevelForSensoryZPP( + state.side, + currentLevel.name, + state.variable, + ); + const variable = state.variable || result.variable; + const zpp = result.level + ? [...state.zpp, result.level] + : [...state.zpp]; + + if (result.continue) { + const nextLevel = currentLevel.next; + const actions: { key: Translation; params?: { [key: string]: string } }[] = + result.level + ? [ + { + key: 'sensoryZPPCheckLevelAddLevelAction', + params: { levelName: result.level ?? currentLevel.name }, + }, + { key: 'sensoryZPPCheckLevelContinueAction' }, + ] + : [{ key: 'sensoryZPPCheckLevelContinueAction' }]; + return createStep( + description, + actions, + state, + { + zpp, + variable, + currentLevel: nextLevel, + }, + nextLevel ? checkLevel : sortSensoryZPP, + ); } + + return createStep( + description, + [ + { + key: 'sensoryZPPCheckLevelAddLevelAction', + params: { levelName: result.level ?? currentLevel.name }, + }, + { key: 'sensoryZPPCheckLevelStopAction' }, + ], + state, + { + zpp, + variable, + currentLevel: null, + }, + sortSensoryZPP, + ); +} + +/* + * This is the third step when calculating the Sensory ZPP. + * Sets the iteration range from S3 (top) to C1 (bottom) and initializes currentLevel. + */ +export function getTopAndBottomLevelsForCheck(state: State): SensoryZPPStep { + const { topLevel, bottomLevel } = buildLevelChainFromS3ToC1(state.side); + + return { + description: { key: 'sensoryZPPGetTopAndBottomLevelsForCheckDescription' }, + actions: [ + { + key: 'sensoryZPPGetTopAndBottomLevelsForCheckRangeAction', + params: { top: topLevel.name, bottom: bottomLevel.name }, + }, + ], + state: { + ...state, + topLevel, + bottomLevel, + currentLevel: topLevel, + }, + next: checkLevel, + }; +} + +/* + * This is the second step when calculating the Sensory ZPP. + * Evaluates S4-5 and optionally adds NA to zpp based on DAP and sacral result. + */ +export function checkSacralLevel(state: State): SensoryZPPStep { + const sacralResult = checkLevelForSensoryZPP( + state.side, + 'S4_5', + state.variable, + ); + + const addNA = + state.deepAnalPressure === 'NT' || + (state.deepAnalPressure === 'No' && + (!sacralResult.continue || sacralResult.level !== undefined)); + + const zpp = addNA ? [...state.zpp, 'NA'] : [...state.zpp]; + + return { + description: { key: 'sensoryZPPCheckSacralLevelDescription' }, + actions: [ + { + key: addNA + ? 'sensoryZPPCheckSacralLevelAddNAAction' + : 'sensoryZPPCheckSacralLevelNoNAAction', + }, + ], + state: { + ...state, + zpp, + // Do NOT propagate sacralResult.variable; original kept variable=false for S3→C1 loop + variable: state.variable, + }, + next: getTopAndBottomLevelsForCheck, + }; } -export const determineSensoryZPP = (side: ExamSide, deepAnalPressure: BinaryObservation): string => { - let zpp = []; - let variable = false; - if ((deepAnalPressure === 'No' || deepAnalPressure === 'NT') && canBeAbsentSensory(side.lightTouch.S4_5) && canBeAbsentSensory(side.pinPrick.S4_5)) { - const sacralResult = checkLevelForSensoryZPP(side, 'S4_5', variable); - if ( - deepAnalPressure === 'NT' || - (deepAnalPressure === 'No' && (!sacralResult.continue || sacralResult.level !== undefined)) - ) { - zpp.push('NA'); - } - - const levels: string[] = []; - for (let i = SensoryLevels.indexOf('S3'); i >= 0; i--) { - const level = SensoryLevels[i]; - - // if not level !== C1 - if (i > 0) { - const result = checkLevelForSensoryZPP(side, level, variable); - variable = variable || result.variable; - if (result.level) { - levels.unshift(result.level); - } - if (result.continue) { - continue; - } else { - break; - } - } else { - // reached end of SensoryLevels - levels.unshift(level); - } - } - zpp = [...zpp, ...levels]; - return zpp.join(','); - } else { - return 'NA'; +/* + * This is the first step when calculating the Sensory ZPP. + * Determines if Sensory ZPP applies or if we return NA immediately based on DAP and S4-5 values. + */ +export function checkIfSensoryZPPIsApplicable( + state: State, +): SensoryZPPStep { + const description: { key: Translation } = { + key: 'sensoryZPPCheckIfSensoryZPPIsApplicableDescription', + }; + const next: SensoryZPPStepHandler = checkSacralLevel; + + if (state.deepAnalPressure === 'Yes') { + return { + description, + actions: [{ key: 'sensoryZPPCheckIfSensoryZPPIsApplicableYesAction' }], + state: { ...state, zpp: ['NA'] }, + next: null, + }; + } + + const s4_5CanBeAbsent = + canBeAbsentSensory(state.side.lightTouch.S4_5) && + canBeAbsentSensory(state.side.pinPrick.S4_5); + + if (!s4_5CanBeAbsent) { + return { + description, + actions: [ + { + key: 'sensoryZPPCheckIfSensoryZPPIsApplicableS4_5PreservedAction', + }, + ], + state: { ...state, zpp: ['NA'] }, + next: null, + }; } -} \ No newline at end of file + + return { + description, + actions: [ + { + key: 'sensoryZPPCheckIfSensoryZPPIsApplicableProceedAction', + }, + ], + state: { ...state, zpp: [], variable: false }, + next, + }; +} + +/* *************************************** */ +/* Initial state and orchestrator */ +/* *************************************** */ + +export function getInitialState( + side: ExamSide, + deepAnalPressure: BinaryObservation, +): State { + return { + side, + deepAnalPressure, + zpp: [], + variable: false, + topLevel: null, + bottomLevel: null, + currentLevel: null, + }; +} + +export function determineSensoryZPP( + side: ExamSide, + deepAnalPressure: BinaryObservation, +): string { + const initialState = getInitialState(side, deepAnalPressure); + let step = checkIfSensoryZPPIsApplicable(initialState); + + while (step.next) { + step = step.next(step.state); + } + + return step.state.zpp.join(','); +} + +/** + * Generator that yields each step of the sensory ZPP calculation. + * Enables step-by-step execution for clinicians to see where each value is generated. + */ +export function* sensoryZPPSteps( + side: ExamSide, + deepAnalPressure: BinaryObservation, +): Generator { + const initialState = getInitialState(side, deepAnalPressure); + let step = checkIfSensoryZPPIsApplicable(initialState); + yield step; + while (step.next) { + step = step.next(step.state); + yield step; + } +} diff --git a/src/classification/zoneOfPartialPreservation/sensoryZPPErrors.ts b/src/classification/zoneOfPartialPreservation/sensoryZPPErrors.ts new file mode 100644 index 0000000..a7962d7 --- /dev/null +++ b/src/classification/zoneOfPartialPreservation/sensoryZPPErrors.ts @@ -0,0 +1,27 @@ +/** + * Centralized error messages for sensory ZPP calculation. + * Enables consistency and future i18n (messages can be used as translation keys). + */ +export const SENSORY_ZPP_ERROR_MESSAGES = { + CHECK_LEVEL_C1_INVALID: + 'checkLevelForSensoryZPP :: invalid argument level: C1', + CURRENT_LEVEL_REQUIRED: 'checkLevel :: state.currentLevel is required.', +} as const; + +export type SensoryZPPErrorCode = keyof typeof SENSORY_ZPP_ERROR_MESSAGES; + +/** + * Domain error for sensory ZPP calculation failures. + * Enables programmatic error handling (e.g. by error code) and consistent messaging. + */ +export class SensoryZPPError extends Error { + readonly code: SensoryZPPErrorCode; + + constructor(code: SensoryZPPErrorCode, message?: string) { + const resolvedMessage = message ?? SENSORY_ZPP_ERROR_MESSAGES[code]; + super(resolvedMessage); + this.name = 'SensoryZPPError'; + this.code = code; + Object.setPrototypeOf(this, SensoryZPPError.prototype); + } +} diff --git a/src/en.ts b/src/en.ts index 10785fa..1192634 100644 --- a/src/en.ts +++ b/src/en.ts @@ -12,24 +12,55 @@ export default { motorZPPGetTopAndBottomLevelsForCheckDoNotIncludeBelowS1Action: 'Since there are not motor levels below S1, we make S1 the bottom of our range.', motorZPPCheckForMotorFunctionDescription: 'Check for motor function on {{levelName}}: {{motor}}.', motorZPPCheckForMotorFunctionNonKeyMuscleOverrideAndStopAction: 'Motor function was found but the lowest non-key muscle with motor function overrides it as it affects the AIS calculation for this case. We stop iterating.', - motorZPPCheckForMotorFunctionAddLevelAndStopAction: 'Motor function was found. We include the level in Motor ZPP and stop iterating.', + motorZPPCheckForMotorFunctionAddLevelAndStopAction: + 'Motor function was found. We include {{levelName}} in Motor ZPP and stop iterating.', motorZPPCheckForMotorFunctionFunctionFoundButKeyMuscleOverrideAction: 'Motor function marked as not normal was found but the lowest non-key muscle with motor function overrides it as it affects the AIS calculation for this case. We continue iterating.', motorZPPCheckForMotorFunctionStopAtTopAction: 'Because we have reached the top level in our range, we stop.', motorZPPCheckForMotorFunctionContinueUntilTopAction: 'Since we have not reached the top level of our range, we continue', motorZPPCheckForMotorFunctionAddStarAction: 'Since motor has a star on this level or above, we add a star to the result.', - motorZPPCheckForMotorFunctionAddLevelWithNormalFunctionAndContinue: 'Motor function marked as not normal was found. We include the level in Motor ZPP and continue.', + motorZPPCheckForMotorFunctionAddLevelWithNormalFunctionAndContinue: + 'Motor function marked as not normal was found. We include {{levelName}} in Motor ZPP and continue.', motorZPPCheckForMotorFunctionNoFunctionFoundContinueAction: 'No motor function was found. We continue.', motorZPPCheckForMotorFunctionTopOfRangeReachedStopAction: 'We reached the top of the searchable range. We stop iterating. Next we will check the lowest non-key muscle with motor function.', motorZPPCheckForSensoryFunctionDescription: 'Check for sensory function on {{levelName}} (LT: {{lightTouch}} - PP: {{pinPrick}})', motorZPPCheckForSensoryFunctionLevelIncludedInMotorValuesAction: '{{levelName}} is included in motor values.', motorZPPCheckForSensoryFunctionLevelIncludedButOverriddenByNonKeyMuscleAction: 'The value, however is overridden by the non-key muscle', - motorZPPCheckForSensoryFunctionAddLevelAndContinueAction: 'We add it to Motor ZPP and continue checking.', + motorZPPCheckForSensoryFunctionAddLevelAndContinueAction: + 'We add {{levelName}} to Motor ZPP and continue checking.', motorZPPCheckForSensoryFunctionTopOfRangeReachedStopAction: 'We are a the top of the range, we stop.', motorZPPCheckForSensoryFunctionNoSensoryFunctionFoundContinueAction: 'No sensory function was found. We continue.', motorZPPSortMotorZPPDescription: 'Sort Motor ZPP', motorZPPSortMotorZPPEnsureNAIsPlacedFirstAction: 'Ensure "NA" is placed first', motorZPPAddLowerNonKeyMuscleToMotorZPPIfNeededDescription: 'If the non-key muscle affects the AIS calculations, we add it to Motor ZPP.', motorZPPAddLowerNonKeyMuscleToMotorZPPIfNeededAddNonKeyMuscleAction: 'We add the lowest non-key muscle with motor function to the Motor ZPP', - motorZPPAddLowerNonKeyMuscleToMotorZPPIfNeededIgnoreNonKeyMuscleAction: 'The lowest non-key muscle either does not have an effect on the AIS or has already been added to Motor ZPP.' + motorZPPAddLowerNonKeyMuscleToMotorZPPIfNeededIgnoreNonKeyMuscleAction: 'The lowest non-key muscle either does not have an effect on the AIS or has already been added to Motor ZPP.', + // Sensory ZPP + sensoryZPPCheckIfSensoryZPPIsApplicableDescription: + 'Check if Deep Anal Pressure and S4-5 allow Sensory ZPP calculation.', + sensoryZPPCheckIfSensoryZPPIsApplicableYesAction: + 'DAP is "Yes". Sensory ZPP is "NA".', + sensoryZPPCheckIfSensoryZPPIsApplicableS4_5PreservedAction: + 'S4-5 has preserved sensation. Sensory ZPP is "NA".', + sensoryZPPCheckIfSensoryZPPIsApplicableProceedAction: + 'DAP is "No" or "NT" and S4-5 sensation is absent. Proceed to evaluate sacral level.', + sensoryZPPCheckSacralLevelDescription: 'Evaluate S4-5 sensory values.', + sensoryZPPCheckSacralLevelAddNAAction: + 'Add "NA" to Sensory ZPP based on DAP and sacral result.', + sensoryZPPCheckSacralLevelNoNAAction: + 'Do not add "NA". Proceed to iterate levels.', + sensoryZPPGetTopAndBottomLevelsForCheckDescription: + 'Set search range from S3 to C1.', + sensoryZPPGetTopAndBottomLevelsForCheckRangeAction: + 'Range: {{top}} (top) to {{bottom}} (bottom).', + sensoryZPPCheckLevelDescription: + 'Check sensory function at {{levelName}} (LT: {{lightTouch}}, PP: {{pinPrick}}).', + sensoryZPPCheckLevelAddLevelAction: 'Add {{levelName}} to Sensory ZPP.', + sensoryZPPCheckLevelContinueAction: 'Continue to next level.', + sensoryZPPCheckLevelStopAction: + 'Sensory function boundary found. Stop iteration.', + sensoryZPPCheckLevelReachedC1Action: 'Reached C1. Add C1 and complete.', + sensoryZPPSortSensoryZPPDescription: 'Sort Sensory ZPP.', + sensoryZPPSortSensoryZPPEnsureNAIsPlacedFirstAction: + 'Ensure "NA" is placed first.', };