Skip to content

Commit

Permalink
Add code to convert a TNM stage to a cancer stage
Browse files Browse the repository at this point in the history
  • Loading branch information
dmpotter44 committed May 10, 2024
1 parent 091ad13 commit 3f5b9d8
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 0 deletions.
23 changes: 23 additions & 0 deletions TNM Staging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Converting TNM to a Staging Value

The current algorithm for determining the numeric staging algorithm is based on
the [TNM classification](https://www.ncbi.nlm.nih.gov/books/NBK553187/) and
works as follows:

1. If M is 1, staging is 4. Skip remaining steps.
2. If N is greater than 0, staging is 3. Skip remaining steps.
3. If T is greater than 2, staging is 2. Skip remaining steps.
4. If T is 1, staging is 1. Skip remaining steps.
5. If T is "is" (in situ), staging is 0. Skip remaining steps.
6. Otherwise, the value is T0 N0 M0 which is effectively "no cancer." This has no corresponding staging, so staging is `null`.

This breaks down as follows:

| T | N | M | Staging |
|--------|------------|----|---------|
| T0 | N0 | M0 | `null` |
| Tis | N0 | M0 | 0 |
| T1, T2 | N0 | M0 | 1 |
| T3, T4 | N0 | M0 | 2 |
| (any) | N1, N2, N3 | M0 | 3 |
| (any) | (any) | M1 | 4 |
95 changes: 95 additions & 0 deletions spec/tnm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { parseTNM, convertTNMToCancerStage, convertTNMValuesToCancerStage } from '../src/tnm';

describe('parseTNM()', () => {
it('parses a simple set of fields', () => {
expect(parseTNM('T1 N0 M0')).toEqual([
{
parameter: 'T',
value: 1,
prefixModifiers: '',
suffixModifiers: ''
},
{
parameter: 'N',
value: 0,
prefixModifiers: '',
suffixModifiers: ''
},
{
parameter: 'M',
value: 0,
prefixModifiers: '',
suffixModifiers: ''
}
]);
});
it('parses "Tis"', () => {
expect(parseTNM('Tis')).toEqual([
{
parameter: 'T',
value: null,
prefixModifiers: '',
suffixModifiers: 'is'
}
]);
});
it('handles junk', () => {
expect(parseTNM('this is invalid')).toEqual([]);
});
});

describe('convertTNMToCancerStage()', () => {
it('returns undefined for invalid values', () => {
expect(convertTNMToCancerStage('completely invalid')).toBe(undefined);
});
it('returns undefined when fields are missing', () => {
expect(convertTNMToCancerStage('missing data T1')).toBe(undefined);
expect(convertTNMToCancerStage('missing data T1 N0')).toBe(undefined);
expect(convertTNMToCancerStage('missing data T1 M0')).toBe(undefined);
expect(convertTNMToCancerStage('missing data N0')).toBe(undefined);
expect(convertTNMToCancerStage('missing data N0 M0')).toBe(undefined);
expect(convertTNMToCancerStage('missing data M0')).toBe(undefined);
expect(convertTNMToCancerStage('This alMost looks valid but is Not')).toBe(undefined);
});
it('returns null for T0 N0 M0', () => {
expect(convertTNMToCancerStage('T0 N0 M0')).toBe(null);
});
it('returns 0 for Tis N0 M0', () => {
expect(convertTNMToCancerStage('Tis N0 M0')).toEqual(0);
expect(convertTNMToCancerStage('Tis N0 M0 with added junk')).toEqual(0);
});
it('returns as expected even with extra field', () => {
expect(convertTNMToCancerStage('pT1 pN0 M0 R0 G1')).toEqual(1);
});
});

describe('convertTNMValuesToCancerStage()', () => {
it('handles T0 N0 M0', () => {
expect(convertTNMValuesToCancerStage(0, 0, 0)).toBeNull();
});
it('converts Tis N0 M0 to 0', () => {
expect(convertTNMValuesToCancerStage(0.5, 0, 0)).toEqual(0);
});
it('converts T1/T2 N0 M0 to 1', () => {
expect(convertTNMValuesToCancerStage(1, 0, 0)).toEqual(1);
expect(convertTNMValuesToCancerStage(2, 0, 0)).toEqual(1);
});
it('converts T3/T4 N0 M0 to 2', () => {
expect(convertTNMValuesToCancerStage(3, 0, 0)).toEqual(2);
expect(convertTNMValuesToCancerStage(4, 0, 0)).toEqual(2);
});
it('converts T? N1/N2/N3 M0 to 3', () => {
for (let t = 0; t <= 4; t++) {
expect(convertTNMValuesToCancerStage(t, 1, 0)).toEqual(3);
expect(convertTNMValuesToCancerStage(t, 2, 0)).toEqual(3);
expect(convertTNMValuesToCancerStage(t, 3, 0)).toEqual(3);
}
});
it('converts T? N? M1 to 4', () => {
for (let t = 1; t <= 4; t++) {
for (let n = 0; n <= 4; n++) {
expect(convertTNMValuesToCancerStage(t, n, 1)).toEqual(4);
}
}
});
});
115 changes: 115 additions & 0 deletions src/tnm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
export type CancerStage = 0 | 1 | 2 | 3 | 4;

export interface TNMField {
/**
* Upper case parameter field
*/
parameter: string;
/**
* The value after this field, *may be null* if there is no number (e.g., Tis)
*/
value: number | null;
/**
* A set of lowercase letters the modify the code that came before the field.
*/
prefixModifiers: string;
/**
* An optional set of suffix modifiers that came after the field.
*/
suffixModifiers: string;
}

/**
* Parses a given string into TNM fields. These fields should be space delimited (e.g., "T1 N0 M0"). If nothing can
* be pulled out of the string, an empty array may be returned.
* @param str the string to parse
*/
export function parseTNM(str: string): TNMField[] {
// First, split by whitespace
const fields = str.split(/\s+/);
// Then parse the individual fields
return fields
.map<TNMField | null>((field) => {
const m = /^([a-z]*)([A-Z])(\d?)([a-z]*)$/.exec(field);
if (m) {
return {
parameter: m[2],
value: m[3] ? parseInt(m[3]) : null,
prefixModifiers: m[1],
suffixModifiers: m[4]
};
} else {
return null;
}
})
.filter((value): value is TNMField => value !== null);
}

/**
* Converts a set of TNM values into cancer stages.
* @param tnm a space-separated set of TNM values
* @returns the converted cancer stage, or null if no cancer was given, or
* undefined if the TNM values couldn't be parsed
*/
export function convertTNMToCancerStage(tnm: string): CancerStage | null | undefined {
// First, attempt to parse this at all
let t: number | undefined = undefined;
let n: number | undefined = undefined;
let m: number | undefined = undefined;
for (const field of parseTNM(tnm)) {
switch (field.parameter) {
case 'T':
if (field.value !== null) {
t = field.value;
} else if (field.suffixModifiers === 'is') {
t = 0.5;
}
break;
case 'N':
if (field.value !== null) {
n = field.value;
}
break;
case 'M':
if (field.value !== null) {
m = field.value;
}
break;
default:
// Ignore this field
}
}
if (typeof t === 'number' && typeof n === 'number' && typeof m === 'number') {
return convertTNMValuesToCancerStage(t, n, m);
} else {
return undefined;
}
}

/**
* Converts a given set of TNM numbers to a corresponding cancer stage.
* @param tumor the tumor number (0-4, with 0.5 being used for Tis)
* @param node the node number
* @param metastasis the metastasis number
* @returns a corresponding cancer stage number
*/
export function convertTNMValuesToCancerStage(tumor: number, node: number, metastasis: number): CancerStage | null {
if (metastasis > 0) {
return 4;
}
if (node > 0) {
return 3;
}
if (tumor > 2) {
return 2;
}
if (tumor >= 1) {
return 1;
}
if (tumor === 0) {
// This is T0 N0 M0 which means "no tumor found" which doesn't really make
// sense
return null;
}
return 0;
}

0 comments on commit 3f5b9d8

Please sign in to comment.