Skip to content

Commit a6a7dbe

Browse files
committed
tweaks
1 parent 237d193 commit a6a7dbe

File tree

6 files changed

+88
-104
lines changed

6 files changed

+88
-104
lines changed

src/app/individual_edit/individual_edit.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ <h2 mat-dialog-title>Edit Individual</h2>
3131
>
3232
<!-- dropdown list -->
3333
<mat-autocomplete #auto="matAutocomplete" autoActiveFirstOption>
34-
@for (term of ageInputService.onsetTerms; track term) {
34+
@for (term of ageInputService.ALLOWED_AGE_LABELS; track term) {
3535
<mat-option [value]="term">
3636
{{ term }}
3737
</mat-option>
@@ -52,7 +52,7 @@ <h2 mat-dialog-title>Edit Individual</h2>
5252
>
5353
<!-- dropdown list -->
5454
<mat-autocomplete #auto="matAutocomplete" autoActiveFirstOption>
55-
@for (term of ageInputService.onsetTerms; track term) {
55+
@for (term of ageInputService.ALLOWED_AGE_LABELS; track term) {
5656
<mat-option [value]="term">
5757
{{ term }}
5858
</mat-option>

src/app/multihpo/multihpo.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
<h2 mat-dialog-title style="margin-bottom: 0;">{{title}}</h2>
12
<mat-dialog-content class="mat-typography">
23
<div class="review-table-container">
34
<table class="custom-curation-table">

src/app/multihpo/multihpo.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class MultiHpoComponent {
6767
const processed = (this.data.concepts ?? [])
6868
.filter(c => {
6969
const text = c.originalText.toLowerCase().trim();
70-
return !NOT_APPLICABLE.has(text) && /[a-zA-Z]/.test(text);
70+
return !NOT_APPLICABLE.has(text) && text.length > 0;
7171
})
7272
.map(c => ({
7373
...c,

src/app/services/age_service.ts

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,44 @@ import { computed, Injectable, signal } from '@angular/core';
22
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
33

44

5+
// regexes for human strings such as 4y2m and 4yrs 2nth
6+
const RE_YEAR = /(\d+(?:\.\d+)?)\s*y/i;
7+
const RE_MONTH = /(\d+(?:\.\d+)?)\s*m/i;
8+
const RE_WEEK = /(\d+(?:\.\d+)?)\s*w/i;
9+
const RE_DAY = /(\d+)\s*d/i;
10+
// Matches ISO 8601 durations like "P1Y6M3D" or "P0D"
11+
const ISO8601_RE = /^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?$/i;
12+
const GESTATIONAL_AGE_RE = /^G(\d+)w(?:([0-6])d)?$/i;
13+
14+
515
@Injectable({
616
providedIn: 'root'
717
})
818
export class AgeInputService {
9-
readonly onsetTerms = ['na', 'Antenatal onset',
19+
readonly ALLOWED_AGE_LABELS = new Set(['na', 'Antenatal onset',
1020
'Embryonal onset', 'Fetal onset',
1121
'Late first trimester onset' , 'Second trimester onset' ,'Third trimester onset',
1222
'Congenital onset',
1323
'Pediatric onset',
1424
'Neonatal onset', 'Infantile onset', 'Childhood onset', 'Juvenile onset' ,
1525
'Adult onset','Young adult onset', 'Early young adult onset' , 'Intermediate young adult onset' , 'Late young adult onset',
16-
'Middle age onset', 'Late onset' ];
26+
'Middle age onset', 'Late onset' ]);
27+
28+
readonly AGE_TERM_MAP: Record<string, string> = {
29+
antenatal: "Antenatal onset",
30+
neonate: "Neonatal onset",
31+
neonatal: "Neonatal onset",
32+
birth: "Congenital onset",
33+
congenital: "Congenital onset",
34+
childhood: "Childhood onset",
35+
adult: "Adult onset",
36+
unk: "na",
37+
na: "na",
38+
};
39+
40+
41+
42+
1743

1844
readonly isoPattern = /^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?$/;
1945
readonly gestationalAgePattern = /^G\d{1,2}w(?:[0-6]d)?$/;
@@ -24,14 +50,14 @@ export class AgeInputService {
2450
readonly selectedTerms = this._selectedTerms.asReadonly();
2551
/** for autocomplete lists */
2652
readonly allAvailableTerms = computed(() => {
27-
return Array.from(new Set([...this.onsetTerms, ...this._selectedTerms()]));
53+
return Array.from(new Set([...this.ALLOWED_AGE_LABELS, ...this._selectedTerms()]));
2854
});
2955
/**
3056
* Returns true if the input is a valid ISO8601 age string, a gestational age string, or a known HPO term ("na" is also an allowed entry)
3157
*/
3258
validateAgeInput(input: string): boolean {
3359
return input == "na" ||
34-
this.onsetTerms.includes(input) ||
60+
this.ALLOWED_AGE_LABELS.has(input) ||
3561
this.isoPattern.test(input) ||
3662
this.gestationalAgePattern.test(input);
3763
}
@@ -67,4 +93,50 @@ export class AgeInputService {
6793
this._selectedTerms.set(["na"]);
6894
}
6995

96+
mapAgeStringToSymbolic(input: string): string | null {
97+
const lower = input.toLowerCase();
98+
if (this.AGE_TERM_MAP[lower]) return this.AGE_TERM_MAP[lower];
99+
if (this.ALLOWED_AGE_LABELS.has(input)) return input;
100+
return null;
101+
}
102+
103+
mapYmdToIso(input: string): string | undefined {
104+
const yMatch = RE_YEAR.exec(input);
105+
const mMatch = RE_MONTH.exec(input);
106+
const wMatch = RE_WEEK.exec(input);
107+
const dMatch = RE_DAY.exec(input);
108+
109+
const yVal = yMatch ? parseFloat(yMatch[1]) : 0;
110+
const mVal = mMatch ? parseFloat(mMatch[1]) : 0;
111+
const wVal = wMatch ? parseFloat(wMatch[1]) : 0;
112+
const dVal = dMatch ? parseFloat(dMatch[1]) : 0;
113+
114+
console.log(`input=${input} y=${yVal} m=${mVal} w=${wVal} d=${dVal}`);
115+
116+
if (yVal === 0 && mVal === 0 && wVal === 0 && dVal === 0) return undefined;
117+
118+
const years = Math.floor(yVal);
119+
const monthsFromY = Math.round((yVal - years) * 12);
120+
const totalMonths = Math.floor(mVal) + monthsFromY;
121+
122+
const daysFromW = Math.round(wVal * 7);
123+
const totalDays = Math.floor(dVal) + daysFromW;
124+
125+
let res = "P";
126+
if (years > 0) res += `${years}Y`;
127+
if (totalMonths > 0) res += `${totalMonths}M`;
128+
if (totalDays > 0) res += `${totalDays}D`;
129+
130+
return res === "P" ? undefined : res;
131+
}
132+
133+
mapEtlAgeString(input: string | null | undefined): string | undefined {
134+
if (! input) return undefined;
135+
const symbolic = this.mapAgeStringToSymbolic(input);
136+
if (symbolic) return symbolic;
137+
if (GESTATIONAL_AGE_RE.test(input)) return input;
138+
if (ISO8601_RE.test(input)) return input;
139+
return this.mapYmdToIso(input);
140+
}
141+
70142
}

src/app/services/etl_session_service.ts

Lines changed: 1 addition & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,6 @@ import { DiseaseData } from "../models/cohort_dto";
55
import { PmidDto } from "../models/pmid_dto";
66

77

8-
const AGE_TERM_MAP: Record<string, string> = {
9-
antenatal: "Antenal onset",
10-
neonate: "Neonatal onset",
11-
neonatal: "Neonatal onset",
12-
"neonatal onset": "Neonatal onset",
13-
newborn: "Neonatal onset",
14-
"newborn onset": "Neonatal onset",
15-
birth: "Congenital onset",
16-
congenital: "Congenital onset",
17-
child: "Childhood onset",
18-
childhood: "Childhood onset",
19-
adult: "Adult onset",
20-
adulthood: "Adult onset",
21-
unk: "na",
22-
na: "na"
23-
};
248

259

2610
// 4. ETL Session Service
@@ -160,76 +144,7 @@ export class EtlSessionService {
160144
return undefined
161145
}
162146

163-
/* Try to parse a variety of age strings to Iso8601.
164-
If another kind of valid age string is found, keep it (.e., Gestational or ISO) */
165-
parseAgeToIso8601(ageStr: string | null | undefined): string | undefined {
166-
if (!ageStr?.trim()) return undefined;
167-
if (this.ageService.validateAgeInput(ageStr)) return ageStr;
168-
const lower = ageStr.trim().toLowerCase();
169-
if (AGE_TERM_MAP[lower]) return AGE_TERM_MAP[lower];
170-
171-
const yearMatch = /(\d+(?:\.\d+)?)\s*(y(?:ear)?s?|yr?s?|yo)\b/.exec(lower);
172-
const monthMatch = /(\d+(?:\.\d+)?)\s*(m(?:onth)?s?|mo?s?)\b/.exec(lower);
173-
const dayMatch = /(\d+)\s*(d(?:ay)?s?)\b/.exec(lower);
174-
175-
if (!yearMatch && !monthMatch && !dayMatch) return undefined;
176-
const rawYears: number = yearMatch ? parseFloat(yearMatch[1]) : 0;
177-
const rawMonths: number = monthMatch ? parseFloat(monthMatch[1]) : 0;
178-
const days: number = dayMatch ? parseInt(dayMatch[1], 10) : 0;
179-
180-
const years = Math.floor(rawYears);
181-
const monthsFromYears = Math.round((rawYears - years) * 12);
182-
const totalMonths = Math.floor(rawMonths) + monthsFromYears;
183-
// Build ISO8601 string
184-
const parts = [
185-
years > 0 ? `${years}Y` : '',
186-
totalMonths > 0 ? `${totalMonths}M` : '',
187-
days > 0 ? `${days}D` : ''
188-
].join('');
189-
return parts ? `P${parts}` : undefined;
190-
}
191-
192-
/**
193-
* Converts a decimal number representing years into ISO 8601 duration format.
194-
* Examples:
195-
* - "4" -> "P4Y"
196-
* - "4.5" -> "P4Y6M"
197-
* - "2.25" -> "P2Y3M"
198-
* - "0.75" -> "P9M"
199-
* @param input - String containing a decimal number
200-
* @returns ISO 8601 duration string or empty string if invalid
201-
*/
202-
parseDecimalYearsToIso8601(input: string | null | undefined): string {
203-
if (input == null || input == undefined) {
204-
return '';
205-
}
206-
const trimmed = input.trim();
207-
// May be integer or decimal
208-
const numberMatch = /^\d+(?:\.\d+)?$/.exec(trimmed);
209-
if (!numberMatch) {
210-
return '';
211-
}
212-
const totalYears = parseFloat(trimmed);
213-
if (isNaN(totalYears) || totalYears < 0) {
214-
return '';
215-
}
216-
const wholeYears = Math.floor(totalYears);
217-
const fractionalYears = totalYears - wholeYears;
218-
const months = Math.round(fractionalYears * 12);
219-
let result = 'P';
220-
if (wholeYears > 0) {
221-
result += `${wholeYears}Y`;
222-
}
223-
if (months > 0) {
224-
result += `${months}M`;
225-
}
226-
// Handle edge case where input is 0
227-
return result === 'P' ? '' : result;
228-
}
229-
230-
231-
232-
147+
233148

234149
private generateId(): string {
235150
return Date.now().toString(36) + Math.random().toString(36).substr(2);

src/app/tableeditor/tableeditor.component.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { TransformType, TransformCategory, StringTransformFn, columnTypeColors,
3636
import { CellReviewComponent } from '../cellreview/cellreview.component';
3737
import { HelpButtonComponent } from "../util/helpbutton/help-button.component";
3838
import { AppStatusService } from '../services/app_status_service';
39+
import { AgeInputService } from '../services/age_service';
3940

4041
export const RAW: EtlCellStatus = 'raw' as EtlCellStatus;
4142
export const TRANSFORMED: EtlCellStatus = 'transformed' as EtlCellStatus;
@@ -69,6 +70,7 @@ export class TableEditorComponent implements OnInit {
6970
Object = Object;
7071

7172
private configService = inject(ConfigService);
73+
private ageService = inject(AgeInputService);
7274
private dialog = inject(MatDialog);
7375
public etl_service = inject(EtlSessionService);
7476
private notificationService = inject(NotificationService);
@@ -149,7 +151,7 @@ export class TableEditorComponent implements OnInit {
149151

150152
/* Functions that perform a fixed operation on cells and DO expect the column type to change */
151153
readonly ELEMENTWISE_MAP: Partial<Record<TransformType, StringTransformFn>> = {
152-
[TransformType.ONSET_AGE]: (val) => this.etl_service.parseAgeToIso8601(val),
154+
[TransformType.ONSET_AGE]: (val) => this.ageService.mapEtlAgeString(val),
153155
[TransformType.SEX_COLUMN]: (val) => this.etl_service.parseSexColumn(val),
154156
[TransformType.SEX_COLUMN_TYPE]: (val) => this.etl_service.parseSexColumn(val),
155157
[TransformType.INDIVIDUAL_ID_COLUMN_TYPE]: (val) => sanitizeString(val),
@@ -1307,11 +1309,9 @@ export class TableEditorComponent implements OnInit {
13071309
break;
13081310
case TransformType.ONSET_AGE:
13091311
case TransformType.LAST_ENCOUNTER_AGE:
1310-
transformed = this.etl_service.parseAgeToIso8601(original);
1311-
break;
13121312
case TransformType.ONSET_AGE_ASSUME_YEARS:
13131313
case TransformType.LAST_ECOUNTER_AGE_ASSUME_YEARS:
1314-
transformed = this.etl_service.parseDecimalYearsToIso8601(original);
1314+
transformed = this.ageService.mapEtlAgeString(original) ?? `Could not convert ${original}`;
13151315
break;
13161316
case TransformType.SEX_COLUMN:
13171317
transformed = this.etl_service.parseSexColumn(original);
@@ -1537,18 +1537,14 @@ export class TableEditorComponent implements OnInit {
15371537
case TransformType.TO_LOWERCASE:
15381538
output = input.toLowerCase();
15391539
break;
1540-
case TransformType.ONSET_AGE:
1541-
output = this.etl_service.parseAgeToIso8601(input);
1542-
break;
1543-
case TransformType.LAST_ENCOUNTER_AGE:
1544-
output = this.etl_service.parseAgeToIso8601(input);
1545-
break;
15461540
case TransformType.SEX_COLUMN:
15471541
output = this.etl_service.parseSexColumn(input);
15481542
break;
1543+
case TransformType.ONSET_AGE:
1544+
case TransformType.LAST_ENCOUNTER_AGE:
15491545
case TransformType.ONSET_AGE_ASSUME_YEARS:
15501546
case TransformType.LAST_ECOUNTER_AGE_ASSUME_YEARS:
1551-
output = this.etl_service.parseDecimalYearsToIso8601(input);
1547+
output = this.ageService.mapEtlAgeString(input);
15521548
break;
15531549
}
15541550
if (output) {

0 commit comments

Comments
 (0)