From 3f5b9d8ef6d233dd42c904ac92226ed2dca92eb5 Mon Sep 17 00:00:00 2001 From: Daniel Potter Date: Fri, 10 May 2024 15:19:28 -0400 Subject: [PATCH 1/3] Add code to convert a TNM stage to a cancer stage --- TNM Staging.md | 23 ++++++++++ spec/tnm.spec.ts | 95 +++++++++++++++++++++++++++++++++++++++ src/tnm.ts | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 TNM Staging.md create mode 100644 spec/tnm.spec.ts create mode 100644 src/tnm.ts diff --git a/TNM Staging.md b/TNM Staging.md new file mode 100644 index 0000000..20a8c07 --- /dev/null +++ b/TNM Staging.md @@ -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 | diff --git a/spec/tnm.spec.ts b/spec/tnm.spec.ts new file mode 100644 index 0000000..644078b --- /dev/null +++ b/spec/tnm.spec.ts @@ -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); + } + } + }); +}); diff --git a/src/tnm.ts b/src/tnm.ts new file mode 100644 index 0000000..5029e66 --- /dev/null +++ b/src/tnm.ts @@ -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((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; +} From 37665580ea5a0fbba7ad39cacaaec8ab121587de Mon Sep 17 00:00:00 2001 From: Daniel Potter Date: Tue, 21 May 2024 15:37:24 -0400 Subject: [PATCH 2/3] Add code to parse TNM data --- spec/tnm.spec.ts | 342 ++++++++++++++++++++++++++++++++++++++++++++++- src/tnm-codes.ts | 232 ++++++++++++++++++++++++++++++++ src/tnm.ts | 95 ++++++++++++- 3 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 src/tnm-codes.ts diff --git a/spec/tnm.spec.ts b/spec/tnm.spec.ts index 644078b..4284811 100644 --- a/spec/tnm.spec.ts +++ b/spec/tnm.spec.ts @@ -1,4 +1,10 @@ -import { parseTNM, convertTNMToCancerStage, convertTNMValuesToCancerStage } from '../src/tnm'; +import { + parseTNM, + convertTNMToCancerStage, + convertTNMValuesToCancerStage, + convertCodeableConceptToTNM, + extractTNM +} from '../src/tnm'; describe('parseTNM()', () => { it('parses a simple set of fields', () => { @@ -32,6 +38,14 @@ describe('parseTNM()', () => { suffixModifiers: 'is' } ]); + expect(parseTNM('cTis')).toEqual([ + { + parameter: 'T', + value: null, + prefixModifiers: 'c', + suffixModifiers: 'is' + } + ]); }); it('handles junk', () => { expect(parseTNM('this is invalid')).toEqual([]); @@ -93,3 +107,329 @@ describe('convertTNMValuesToCancerStage()', () => { } }); }); + +describe('convertCodeableConcetpToTNM', () => { + it('handles blank codeable concepts', () => { + // Everything in a codeable concept is undefined, so nothing is valid + expect(convertCodeableConceptToTNM({})).toBeUndefined(); + }); + it('handles a codeable concept with a system but no code', () => { + expect( + convertCodeableConceptToTNM({ + coding: [ + { + system: 'http://snomed.info/sct' + } + ] + }) + ).toBeUndefined(); + }); + it('handles a codeable concept with a code but no system', () => { + expect( + convertCodeableConceptToTNM({ + coding: [ + { + code: '1228923003' + } + ] + }) + ).toBeUndefined(); + }); + it('handles a codeable concept with an unknown system/code', () => { + expect( + convertCodeableConceptToTNM({ + coding: [ + { + system: 'http://www.example.com/invalid', + code: 'also-invalid' + } + ] + }) + ).toBeUndefined(); + }); +}); + +describe('extractTNM()', () => { + it('does not overwrite with later values', () => { + // This tests "early quit" sort of + expect( + extractTNM([ + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1228923003' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229897000' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229913001' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1228904005' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229889007' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229901006' + } + ] + } + } + ]) + ).toEqual({ + tumor: 4, + node: 3, + metastasis: 1 + }); + // So check each field individually as well + expect( + extractTNM([ + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1228923003' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1228904005' + } + ] + } + } + ]) + ).toEqual({ + tumor: 4 + }); + expect( + extractTNM([ + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229897000' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229889007' + } + ] + } + } + ]) + ).toEqual({ + node: 3 + }); + expect( + extractTNM([ + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229913001' + } + ] + } + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229901006' + } + ] + } + } + ]) + ).toEqual({ + metastasis: 1 + }); + }); + it('extracts expected values', () => { + expect( + extractTNM([ + // Observation with an unknown system + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://example.com/ignore-this', + code: '1229901006' + } + ] + } + }, + // Code that's valid FHIR but will never match anything + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: 'invalid SNOMED code' + } + ] + } + }, + // Non-observation resource that should be ignored + { + resourceType: 'Patient' + }, + // Tumor observation + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1228869002' + } + ] + } + }, + // Node observation + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229889007' + } + ] + } + }, + // Metastasis observation + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1229901006' + } + ] + } + } + ]) + ).toEqual({ + tumor: 1, + node: 1, + metastasis: 0 + }); + }); + it('handles Observations without valueCodeableConcepts', () => { + expect( + extractTNM([ + { + resourceType: 'Observation', + code: {}, + status: 'final', + valueBoolean: true + } + ]) + ).toEqual({}); + }); +}); diff --git a/src/tnm-codes.ts b/src/tnm-codes.ts new file mode 100644 index 0000000..30395af --- /dev/null +++ b/src/tnm-codes.ts @@ -0,0 +1,232 @@ +// This was originally going to be a JSON file, but importing JSON files proved problematic. +// Doing it as TypeScript also allows type information to be included. + +/** + * Valid stage values for a tumor stage + */ +export type TumorStage = 0 | 1 | 2 | 3 | 4 | 'is'; +export type NodeStage = 0 | 1 | 2 | 3; +export type MetastasisStage = 0 | 1; + +export type TNMStage = TumorStage | NodeStage | MetastasisStage; + +export type SystemCodeMap = Record>; + +export interface TNMCodes extends Record<'T' | 'N' | 'M', SystemCodeMap> { + T: SystemCodeMap; + N: SystemCodeMap; + M: SystemCodeMap; +} + +export const TNM_CODES: TNMCodes = { + T: { + 'http://snomed.info/sct': { + '1228863001': 0, + '1228882005': 0, + '1228951007': 0, + '1228865008': 'is', + '1228866009': 'is', + '1228867000': 'is', + '1228868005': 'is', + '1228884006': 'is', + '1228885007': 'is', + '1228887004': 'is', + '1228888009': 'is', + '1228953005': 'is', + '1228954004': 'is', + '1228955003': 'is', + '1228956002': 'is', + '1228869002': 1, + '1228870001': 1, + '1228872009': 1, + '1228873004': 1, + '1228874005': 1, + '1228889001': 1, + '1228891009': 1, + '1228892002': 1, + '1228893007': 1, + '1228894001': 1, + '1228895000': 1, + '1228896004': 1, + '1228897008': 1, + '1228898003': 1, + '1228899006': 1, + '1228900001': 1, + '1228901002': 1, + '1228902009': 1, + '1228903004': 1, + '1228904005': 1, + '1228905006': 1, + '1228906007': 1, + '1228907003': 1, + '1228908008': 1, + '1228909000': 1, + '1228928007': 1, + '1228957006': 1, + '1228958001': 1, + '1228959009': 1, + '1228960004': 1, + '1228961000': 1, + '1228962007': 1, + '1229844006': 1, + '1229845007': 1, + '1229846008': 1, + '1229848009': 1, + '1229849001': 1, + '1229850001': 1, + '1229851002': 1, + '1285094009': 1, + '1285095005': 1, + '1285096006': 1, + '1228910005': 2, + '1228911009': 2, + '1228912002': 2, + '1228913007': 2, + '1228914001': 2, + '1228915000': 2, + '1228916004': 2, + '1228929004': 2, + '1228931008': 2, + '1228932001': 2, + '1228933006': 2, + '1228934000': 2, + '1228936003': 2, + '1228937007': 2, + '1229852009': 2, + '1229853004': 2, + '1229854005': 2, + '1229855006': 2, + '1229856007': 2, + '1229857003': 2, + '1229858008': 2, + '1228917008': 3, + '1228918003': 3, + '1228919006': 3, + '1228920000': 3, + '1228921001': 3, + '1228938002': 3, + '1228939005': 3, + '1228940007': 3, + '1228941006': 3, + '1228942004': 3, + '1228943009': 3, + '1229859000': 3, + '1229860005': 3, + '1229861009': 3, + '1229862002': 3, + '1229863007': 3, + '1228922008': 4, + '1228923003': 4, + '1228924009': 4, + '1228925005': 4, + '1228926006': 4, + '1228927002': 4, + '1228944003': 4, + '1228945002': 4, + '1228946001': 4, + '1228947005': 4, + '1228948000': 4, + '1228949008': 4, + '1229864001': 4, + '1229865000': 4, + '1229866004': 4, + '1229867008': 4, + '1229868003': 4, + '1229869006': 4 + } + }, + N: { + 'http://snomed.info/sct': { + '1229878000': 0, + '1229879008': 0, + '1229880006': 0, + '1229881005': 0, + '1229947003': 0, + '1229948008': 0, + '1229949000': 0, + '1229950000': 0, + '1229967007': 0, + '1229968002': 0, + '1229969005': 0, + '1229971005': 0, + '1229884002': 1, + '1229885001': 1, + '1229887009': 1, + '1229888004': 1, + '1229889007': 1, + '1229890003': 1, + '1229951001': 1, + '1229952008': 1, + '1229953003': 1, + '1229954009': 1, + '1229955005': 1, + '1229956006': 1, + '1229973008': 1, + '1229974002': 1, + '1229975001': 1, + '1229976000': 1, + '1229977009': 1, + '1229886000': 2, + '1229892006': 2, + '1229893001': 2, + '1229894007': 2, + '1229896009': 2, + '1229957002': 2, + '1229958007': 2, + '1229959004': 2, + '1229960009': 2, + '1229961008': 2, + '1229978004': 2, + '1229980005': 2, + '1229981009': 2, + '1229982002': 2, + '1229983007': 2, + '1229897000': 3, + '1229898005': 3, + '1229899002': 3, + '1229900007': 3, + '1229962001': 3, + '1229963006': 3, + '1229964000': 3, + '1229965004': 3, + '1229984001': 3, + '1229985000': 3, + '1229986004': 3, + '1229987008': 3 + } + }, + M: { + 'http://snomed.info/sct': { + '1229901006': 0, + '1229902004': 0, + '1229903009': 1, + '1229904003': 1, + '1229905002': 1, + '1229906001': 1, + '1229907005': 1, + '1229908000': 1, + '1229909008': 1, + '1229910003': 1, + '1229911004': 1, + '1229912006': 1, + '1229913001': 1, + '1229914007': 1, + '1229915008': 1, + '1229916009': 1, + '1229917000': 1, + '1229918005': 1, + '1229919002': 1, + '1229920008': 1, + '1229921007': 1, + '1229922000': 1, + '1229923005': 1, + '1229924004': 1, + '1229925003': 1, + '1229926002': 1, + '1229927006': 1, + '1229928001': 1 + } + } +}; + +export default TNM_CODES; diff --git a/src/tnm.ts b/src/tnm.ts index 5029e66..040a12a 100644 --- a/src/tnm.ts +++ b/src/tnm.ts @@ -1,3 +1,6 @@ +import { Bundle, CodeableConcept, FhirResource, Observation } from "fhir/r4"; +import TNM_CODES, { MetastasisStage, NodeStage, TumorStage, TNMStage, TNMCodes } from './tnm-codes'; + export type CancerStage = 0 | 1 | 2 | 3 | 4; export interface TNMField { @@ -91,7 +94,7 @@ export function convertTNMToCancerStage(tnm: string): CancerStage | null | undef * @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 + * @returns a corresponding cancer stage number, or null for "no cancer" */ export function convertTNMValuesToCancerStage(tumor: number, node: number, metastasis: number): CancerStage | null { if (metastasis > 0) { @@ -113,3 +116,93 @@ export function convertTNMValuesToCancerStage(tumor: number, node: number, metas } return 0; } + +export interface TNMStageValue { + type: 'T' | 'N' | 'M'; + stage: TNMStage; +} + +/** + * Attempts to convert the given codeable concept into a TNM stage value. The + * stage value may be a single T, N, or M stage value. + * + * If the code cannot be converted, this returns undefined. + * @param concept the concept to decode + */ +export function convertCodeableConceptToTNM(concept: CodeableConcept): TNMStageValue | undefined { + const codes = concept.coding; + if (Array.isArray(codes)) { + // Have codes to check + for (const code of codes) { + // See if we have a code to check at all + if (code.system && code.code) { + // See if this code exists + for (const type in TNM_CODES) { + const systems = TNM_CODES[type as keyof TNMCodes]; + if (code.system in systems && code.code in systems[code.system]) { + const value = systems[code.system][code.code]; + return { + type: type as keyof TNMCodes, + stage: value, + } + } + } + } + } + } + // If we've fallen through, we never found a matching code, so return undefined + // (which will happen automatically, but make it explicit anyway) + return undefined; +} + +export interface TNMValues { + tumor?: TumorStage; + node?: NodeStage; + metastasis?: MetastasisStage; +} + +/** + * Extracts a set of TNM values (if possible) from the given set of resources. + * This will attempt to extract the first TNM values found in the list. If + * multiple resources define a TNM value, only the first found is used. So if + * there are two resources, the first which gives a value of T1, and the second + * that gives a value of T2, this returns `{ tumor: 1 }`. + * @param resources the resources to extra from + * @returns the TNM values that could be extracted + */ +export function extractTNM(resources: FhirResource[]): TNMValues { + const result: TNMValues = {}; + for (const resource of resources) { + if (resource.resourceType === 'Observation') { + // TODO: Ignore resources with status = 'entered-in-error'? + // Take it at its word (sort of) + const observation = resource as Observation; + if (observation.valueCodeableConcept) { + // Try and look this up + const value = convertCodeableConceptToTNM(observation.valueCodeableConcept); + if (value) { + switch (value.type) { + case 'T': + if (result.tumor === undefined) + result.tumor = value.stage; + break; + case 'N': + // Assume the values coming out of convertCodeableConceptToTNM are right + if (result.node === undefined) + result.node = value.stage as NodeStage; + break; + case 'M': + if (result.metastasis === undefined) + result.metastasis = value.stage as MetastasisStage; + break; + } + // If we have all values, return immediately + if (result.tumor !== undefined && result.node !== undefined && result.metastasis !== undefined) { + return result; + } + } + } + } + } + return result; +} From 3f9e2e40d1ddcc1cd7dd6147f24d82b73005ccb4 Mon Sep 17 00:00:00 2001 From: Daniel Potter Date: Fri, 31 May 2024 15:29:53 -0400 Subject: [PATCH 3/3] Check observation code --- spec/fhir-util.spec.ts | 150 +++++++++++++++- spec/tnm.spec.ts | 376 ++++++++++++++++++----------------------- src/fhir-constants.ts | 8 + src/fhir-util.ts | 52 +++++- src/index.ts | 1 + src/tnm-codes.ts | 23 ++- src/tnm.ts | 67 ++++++-- 7 files changed, 447 insertions(+), 230 deletions(-) create mode 100644 src/fhir-constants.ts diff --git a/spec/fhir-util.spec.ts b/spec/fhir-util.spec.ts index c1cd84b..d1b3795 100644 --- a/spec/fhir-util.spec.ts +++ b/spec/fhir-util.spec.ts @@ -1,5 +1,11 @@ import { FhirResource } from 'fhir/r4'; -import { FHIRDate, FHIRDateAccuracy, resourceContainsProfile } from '../src/fhir-util'; +import { + FHIRDate, + FHIRDateAccuracy, + codeableConceptContains, + codeableConceptContainsCode, + resourceContainsProfile +} from '../src/fhir-util'; describe('#resourceContainsProfile', () => { it('handles resources with no meta', () => { @@ -117,3 +123,145 @@ describe('FHIRDate', () => { }); }); }); + +describe('codeableConceptContains', () => { + // Simple test set + const CODE_SET = { '1_2': ['1', '2'], '3_4': ['3', '4'] }; + it('handles a codeable concept with no codes', () => { + expect(codeableConceptContains({}, CODE_SET)).toBeFalse(); + }); + it('returns false for codes in the other system', () => { + expect( + codeableConceptContains( + { + coding: [ + { + system: '1_2', + code: '3' + }, + { + system: '3_4', + code: '1' + } + ] + }, + CODE_SET + ) + ).toBeFalse(); + }); + it('handles partial codes', () => { + expect( + codeableConceptContains( + { + coding: [ + { + code: '1' + }, + { + system: '3_4' + } + ] + }, + CODE_SET + ) + ).toBeFalse(); + }); + it('returns true for codes that exist', () => { + expect( + codeableConceptContains( + { + coding: [ + { + system: 'ignore me', + code: 'code' + }, + { + system: '3_4', + code: '4' + } + ] + }, + CODE_SET + ) + ).toBeTrue(); + }); +}); + +describe('codeableConceptContainsCode', () => { + // Simple test set + const SYSTEM = '1_2'; + const CODE_SET = ['1', '2']; + it('handles a codeable concept with no codes', () => { + expect(codeableConceptContainsCode({}, SYSTEM, CODE_SET)).toBeFalse(); + }); + it("returns false for codes that don't exist", () => { + expect( + codeableConceptContainsCode( + { + coding: [ + { + system: '1_2', + code: '3' + } + ] + }, + SYSTEM, + CODE_SET + ) + ).toBeFalse(); + }); + it('returns false for codes in another system', () => { + expect( + codeableConceptContainsCode( + { + coding: [ + { + system: '3_4', + code: '1' + } + ] + }, + SYSTEM, + CODE_SET + ) + ).toBeFalse(); + }); + it('handles partial codes', () => { + expect( + codeableConceptContainsCode( + { + coding: [ + { + code: '1' + }, + { + system: '3_4' + } + ] + }, + SYSTEM, + CODE_SET + ) + ).toBeFalse(); + }); + it('returns true for codes that exist', () => { + expect( + codeableConceptContainsCode( + { + coding: [ + { + system: 'ignore me', + code: 'code' + }, + { + system: '1_2', + code: '2' + } + ] + }, + SYSTEM, + CODE_SET + ) + ).toBeTrue(); + }); +}); diff --git a/spec/tnm.spec.ts b/spec/tnm.spec.ts index 4284811..1cb5fb1 100644 --- a/spec/tnm.spec.ts +++ b/spec/tnm.spec.ts @@ -1,3 +1,4 @@ +import { Observation } from 'fhir/r4'; import { parseTNM, convertTNMToCancerStage, @@ -5,6 +6,37 @@ import { convertCodeableConceptToTNM, extractTNM } from '../src/tnm'; +import { SNOMED_SYSTEM_URI } from '../src/fhir-constants'; +import TNM_CODES, { + TNM_SNOMED_TUMOR_CODES, + TNM_SNOMED_NODE_CODES, + TNM_SNOMED_METASTASIS_CODES +} from '../src/tnm-codes'; + +// To make the tests more readable, grab out a code to use for each TNM type +const SNOMED_TUMOR_CODE = TNM_SNOMED_TUMOR_CODES[0]; +const SNOMED_NODE_CODE = TNM_SNOMED_NODE_CODES[0]; +const SNOMED_METASTASIS_CODE = TNM_SNOMED_METASTASIS_CODES[0]; + +// Function to find a matching code +function find(o: Record, value: C): T { + const r = Object.entries(o).find(([, v]) => v === value); + if (r) { + return r[0] as T; + } else { + throw new Error(`No value ${value} found`); + } +} + +// Various codes that map to various values - again, to make the tests readable +// (as well as ensure mapping changes can't break things) +const SNOMED_T1_CODE = find(TNM_CODES['T'][SNOMED_SYSTEM_URI], 1); +const SNOMED_T4_CODE = find(TNM_CODES['T'][SNOMED_SYSTEM_URI], 4); +const SNOMED_N1_CODE = find(TNM_CODES['N'][SNOMED_SYSTEM_URI], 1); +const SNOMED_N2_CODE = find(TNM_CODES['N'][SNOMED_SYSTEM_URI], 2); +const SNOMED_N3_CODE = find(TNM_CODES['N'][SNOMED_SYSTEM_URI], 3); +const SNOMED_M0_CODE = find(TNM_CODES['M'][SNOMED_SYSTEM_URI], 0); +const SNOMED_M1_CODE = find(TNM_CODES['M'][SNOMED_SYSTEM_URI], 1); describe('parseTNM()', () => { it('parses a simple set of fields', () => { @@ -118,7 +150,7 @@ describe('convertCodeableConcetpToTNM', () => { convertCodeableConceptToTNM({ coding: [ { - system: 'http://snomed.info/sct' + system: SNOMED_SYSTEM_URI } ] }) @@ -149,188 +181,79 @@ describe('convertCodeableConcetpToTNM', () => { }); }); +/** + * + * @param code the code of the thing being observed + * @param value the value + * @returns an Observation + */ +function createObservation(code: string, value: string): Observation { + return { + resourceType: 'Observation', + code: { + coding: [ + { + system: SNOMED_SYSTEM_URI, + code: code + } + ] + }, + status: 'final', + valueCodeableConcept: { + coding: [ + { + system: SNOMED_SYSTEM_URI, + code: value + } + ] + } + }; +} + describe('extractTNM()', () => { it('does not overwrite with later values', () => { - // This tests "early quit" sort of + // This tests "early quit" sort of... expect( extractTNM([ - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1228923003' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229897000' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229913001' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1228904005' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229889007' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229901006' - } - ] - } - } + // T observation + createObservation(SNOMED_TUMOR_CODE, SNOMED_T4_CODE), + // N observation + createObservation(SNOMED_NODE_CODE, SNOMED_N3_CODE), + // M observation + createObservation(SNOMED_METASTASIS_CODE, SNOMED_M1_CODE), + // T observation + createObservation(SNOMED_TUMOR_CODE, SNOMED_T1_CODE), + // N observation + createObservation(SNOMED_NODE_CODE, SNOMED_N1_CODE), + // M observation + createObservation(SNOMED_METASTASIS_CODE, SNOMED_M0_CODE) ]) ).toEqual({ tumor: 4, node: 3, metastasis: 1 }); - // So check each field individually as well + // ...so check each field individually as well expect( extractTNM([ - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1228923003' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1228904005' - } - ] - } - } + createObservation(SNOMED_TUMOR_CODE, SNOMED_T4_CODE), + createObservation(SNOMED_TUMOR_CODE, SNOMED_T1_CODE) ]) ).toEqual({ tumor: 4 }); expect( extractTNM([ - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229897000' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229889007' - } - ] - } - } + createObservation(SNOMED_NODE_CODE, SNOMED_N3_CODE), + createObservation(SNOMED_NODE_CODE, SNOMED_N1_CODE) ]) ).toEqual({ node: 3 }); expect( extractTNM([ - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229913001' - } - ] - } - }, - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229901006' - } - ] - } - } + createObservation(SNOMED_METASTASIS_CODE, SNOMED_M1_CODE), + createObservation(SNOMED_METASTASIS_CODE, SNOMED_M0_CODE) ]) ).toEqual({ metastasis: 1 @@ -342,77 +265,36 @@ describe('extractTNM()', () => { // Observation with an unknown system { resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { + code: { coding: [ { - system: 'http://example.com/ignore-this', - code: '1229901006' + system: SNOMED_SYSTEM_URI, + code: SNOMED_NODE_CODE } ] - } - }, - // Code that's valid FHIR but will never match anything - { - resourceType: 'Observation', - code: {}, + }, status: 'final', valueCodeableConcept: { coding: [ { - system: 'http://snomed.info/sct', - code: 'invalid SNOMED code' + system: 'http://example.com/ignore-this', + code: SNOMED_T1_CODE } ] } }, + // Code that's valid FHIR but will never match anything + createObservation(SNOMED_NODE_CODE, 'invalid SNOMED code'), // Non-observation resource that should be ignored { resourceType: 'Patient' }, // Tumor observation - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1228869002' - } - ] - } - }, + createObservation(SNOMED_TUMOR_CODE, SNOMED_T1_CODE), // Node observation - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229889007' - } - ] - } - }, + createObservation(SNOMED_NODE_CODE, SNOMED_N1_CODE), // Metastasis observation - { - resourceType: 'Observation', - code: {}, - status: 'final', - valueCodeableConcept: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '1229901006' - } - ] - } - } + createObservation(SNOMED_METASTASIS_CODE, SNOMED_M0_CODE) ]) ).toEqual({ tumor: 1, @@ -420,12 +302,82 @@ describe('extractTNM()', () => { metastasis: 0 }); }); + it('ignores code/value mismatches when checking codes', () => { + expect( + extractTNM([ + // N code as a value to a T code + createObservation(SNOMED_TUMOR_CODE, SNOMED_N1_CODE) + ]) + ).toEqual({}); + expect( + extractTNM([ + // M code as a value to a N code + createObservation(SNOMED_NODE_CODE, SNOMED_M0_CODE) + ]) + ).toEqual({}); + expect( + extractTNM([ + // N code as a value to a T code + createObservation(SNOMED_TUMOR_CODE, SNOMED_N1_CODE), + // Use something invalid for the observed thing code + createObservation('invalid', SNOMED_N2_CODE), + createObservation(SNOMED_NODE_CODE, SNOMED_N3_CODE) + ]) + ).toEqual({ + node: 3 + }); + }); + it('returns code/value mismatches when not checking codes', () => { + expect( + extractTNM( + [ + // N code as a value to a T code + createObservation(SNOMED_TUMOR_CODE, SNOMED_N1_CODE) + ], + { checkCodes: false } + ) + ).toEqual({ + node: 1 + }); + expect( + extractTNM( + [ + // M code as a value to a N code + createObservation(SNOMED_NODE_CODE, SNOMED_M0_CODE) + ], + { checkCodes: false } + ) + ).toEqual({ + metastasis: 0 + }); + expect( + extractTNM( + [ + // N code as a value to a T code + createObservation(SNOMED_TUMOR_CODE, SNOMED_N1_CODE), + // Use something invalid for the observed thing code + createObservation('invalid', SNOMED_N2_CODE), + createObservation(SNOMED_NODE_CODE, SNOMED_N3_CODE) + ], + { checkCodes: false } + ) + ).toEqual({ + node: 1 + }); + }); it('handles Observations without valueCodeableConcepts', () => { expect( extractTNM([ { resourceType: 'Observation', - code: {}, + code: { + coding: [ + { + system: SNOMED_SYSTEM_URI, + code: SNOMED_METASTASIS_CODE + } + ] + }, status: 'final', valueBoolean: true } diff --git a/src/fhir-constants.ts b/src/fhir-constants.ts new file mode 100644 index 0000000..6e7b9e0 --- /dev/null +++ b/src/fhir-constants.ts @@ -0,0 +1,8 @@ +/** + * Constants use with FHIR + */ + +/** + * SNOMED system URI within codes. + */ +export const SNOMED_SYSTEM_URI = 'http://snomed.info/sct'; diff --git a/src/fhir-util.ts b/src/fhir-util.ts index 173742a..b934b34 100644 --- a/src/fhir-util.ts +++ b/src/fhir-util.ts @@ -1,4 +1,4 @@ -import { FhirResource } from 'fhir/r4'; +import { CodeableConcept, FhirResource } from 'fhir/r4'; export enum FHIRDateAccuracy { YEAR, @@ -134,6 +134,56 @@ export class FHIRDate { } } +/** + * Checks to see if any of the given codes are in the given codeable concept. + * @param concept the concept to check + * @param system the expected system for the codes to check + * @param codes the expected codes + * @returns true if any code is found, false otherwise + */ +export function codeableConceptContainsCode(concept: CodeableConcept, system: string, codes: string[]): boolean { + if (Array.isArray(concept.coding)) { + for (const code of concept.coding) { + if (typeof code === 'object' && code.system === system && code.code && codes.includes(code.code)) { + return true; + } + } + } + return false; +} + +/** + * Checks to see if any of the given codeable concept contains any code in the + * mapping of coding systems to codes. + * + * The codes are a mapping of systems to codes, for example: + * + * ``` + * { + * "http://snomed.info/sct": [ "277206009", "277208005" ] + * } + * ``` + * @param concept the concept to check + * @param codes a mapping of systems to codes to check + * @returns + */ +export function codeableConceptContains(concept: CodeableConcept, codes: Record): boolean { + if (Array.isArray(concept.coding)) { + for (const code of concept.coding) { + if ( + typeof code === 'object' && + code.system && + code.code && + code.system in codes && + codes[code.system].includes(code.code) + ) { + return true; + } + } + } + return false; +} + /** * Checks to see if a given resource contains a requested profile. * @param resource the FHIR resource to check diff --git a/src/index.ts b/src/index.ts index c66bc3c..e111e8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export * from './research-study'; export * from './searchset'; export * from './clinicaltrialsgov'; export * from './fhir-util'; +export * from './tnm'; export { CodeMapper, CodeSystemEnum } from './codeMapper'; export * from './mcodeextractor'; export { Study } from './ctg-api'; diff --git a/src/tnm-codes.ts b/src/tnm-codes.ts index 30395af..c8dbb1f 100644 --- a/src/tnm-codes.ts +++ b/src/tnm-codes.ts @@ -1,6 +1,8 @@ // This was originally going to be a JSON file, but importing JSON files proved problematic. // Doing it as TypeScript also allows type information to be included. +import { SNOMED_SYSTEM_URI } from './fhir-constants'; + /** * Valid stage values for a tumor stage */ @@ -18,9 +20,24 @@ export interface TNMCodes extends Record<'T' | 'N' | 'M', SystemCodeMap; } +/** + * Codes that represent various types of TNM tumor codes. + */ +export const TNM_SNOMED_TUMOR_CODES = ['78873005', '399504009', '384625004']; + +/** + * Codes that represent various types of TNM node codes. + */ +export const TNM_SNOMED_NODE_CODES = ['277206009', '399534004', '371494008']; + +/** + * Codes that represent various types of TNM metastasis codes. + */ +export const TNM_SNOMED_METASTASIS_CODES = ['277208005', '399387003', '371497001']; + export const TNM_CODES: TNMCodes = { T: { - 'http://snomed.info/sct': { + [SNOMED_SYSTEM_URI]: { '1228863001': 0, '1228882005': 0, '1228951007': 0, @@ -136,7 +153,7 @@ export const TNM_CODES: TNMCodes = { } }, N: { - 'http://snomed.info/sct': { + [SNOMED_SYSTEM_URI]: { '1229878000': 0, '1229879008': 0, '1229880006': 0, @@ -196,7 +213,7 @@ export const TNM_CODES: TNMCodes = { } }, M: { - 'http://snomed.info/sct': { + [SNOMED_SYSTEM_URI]: { '1229901006': 0, '1229902004': 0, '1229903009': 1, diff --git a/src/tnm.ts b/src/tnm.ts index 040a12a..7bf36b5 100644 --- a/src/tnm.ts +++ b/src/tnm.ts @@ -1,5 +1,16 @@ -import { Bundle, CodeableConcept, FhirResource, Observation } from "fhir/r4"; -import TNM_CODES, { MetastasisStage, NodeStage, TumorStage, TNMStage, TNMCodes } from './tnm-codes'; +import { CodeableConcept, FhirResource, Observation } from 'fhir/r4'; +import TNM_CODES, { + MetastasisStage, + NodeStage, + TumorStage, + TNMStage, + TNMCodes, + TNM_SNOMED_TUMOR_CODES, + TNM_SNOMED_NODE_CODES, + TNM_SNOMED_METASTASIS_CODES +} from './tnm-codes'; +import { SNOMED_SYSTEM_URI } from './fhir-constants'; +import { codeableConceptContainsCode } from './fhir-util'; export type CancerStage = 0 | 1 | 2 | 3 | 4; @@ -79,7 +90,7 @@ export function convertTNMToCancerStage(tnm: string): CancerStage | null | undef } break; default: - // Ignore this field + // Ignore this field } } if (typeof t === 'number' && typeof n === 'number' && typeof m === 'number') { @@ -143,8 +154,8 @@ export function convertCodeableConceptToTNM(concept: CodeableConcept): TNMStageV const value = systems[code.system][code.code]; return { type: type as keyof TNMCodes, - stage: value, - } + stage: value + }; } } } @@ -161,6 +172,28 @@ export interface TNMValues { metastasis?: MetastasisStage; } +export interface ExtractTNMOptions { + checkCodes?: boolean; +} + +/** + * Checks the code from a given observation to check + * @param observation the observation to check + */ +function expectedTNM(observation: Observation): 'T' | 'N' | 'M' | null { + const code = observation.code; + if (codeableConceptContainsCode(code, SNOMED_SYSTEM_URI, TNM_SNOMED_TUMOR_CODES)) { + return 'T'; + } + if (codeableConceptContainsCode(code, SNOMED_SYSTEM_URI, TNM_SNOMED_NODE_CODES)) { + return 'N'; + } + if (codeableConceptContainsCode(code, SNOMED_SYSTEM_URI, TNM_SNOMED_METASTASIS_CODES)) { + return 'M'; + } + return null; +} + /** * Extracts a set of TNM values (if possible) from the given set of resources. * This will attempt to extract the first TNM values found in the list. If @@ -170,30 +203,38 @@ export interface TNMValues { * @param resources the resources to extra from * @returns the TNM values that could be extracted */ -export function extractTNM(resources: FhirResource[]): TNMValues { +export function extractTNM(resources: FhirResource[], options?: ExtractTNMOptions): TNMValues { + // Default to checking codes + const checkCodes = options?.checkCodes ?? true; const result: TNMValues = {}; for (const resource of resources) { if (resource.resourceType === 'Observation') { // TODO: Ignore resources with status = 'entered-in-error'? - // Take it at its word (sort of) + // Are there any other statuses that should be ignored? const observation = resource as Observation; + const expectedType = checkCodes ? expectedTNM(observation) : null; + if (checkCodes && expectedType === null) { + // No code found, skip this resource + continue; + } if (observation.valueCodeableConcept) { // Try and look this up const value = convertCodeableConceptToTNM(observation.valueCodeableConcept); if (value) { + if (checkCodes && value.type != expectedType) { + // If this doesn't match, skip this + continue; + } switch (value.type) { case 'T': - if (result.tumor === undefined) - result.tumor = value.stage; + if (result.tumor === undefined) result.tumor = value.stage; break; case 'N': // Assume the values coming out of convertCodeableConceptToTNM are right - if (result.node === undefined) - result.node = value.stage as NodeStage; + if (result.node === undefined) result.node = value.stage as NodeStage; break; case 'M': - if (result.metastasis === undefined) - result.metastasis = value.stage as MetastasisStage; + if (result.metastasis === undefined) result.metastasis = value.stage as MetastasisStage; break; } // If we have all values, return immediately