diff --git a/src/src/app/dive-planner-service/BuhlmannZHL16C.spec.ts b/src/src/app/dive-planner-service/BuhlmannZHL16C.spec.ts new file mode 100644 index 00000000..bc266cc2 --- /dev/null +++ b/src/src/app/dive-planner-service/BuhlmannZHL16C.spec.ts @@ -0,0 +1,95 @@ +import { BuhlmannZHL16C } from './BuhlmannZHL16C'; +import { BreathingGas } from './BreathingGas'; +import { DiveSettingsService } from './DiveSettings.service'; +import { DiveSegment } from './DiveSegment'; + +describe('BuhlmannZHL16C', () => { + let diveSettings: DiveSettingsService; + let air: BreathingGas; + let model: BuhlmannZHL16C; + + beforeEach(() => { + diveSettings = new DiveSettingsService(); + // Initialize StandardGases to avoid the find error + BreathingGas.StandardGases = []; + air = BreathingGas.create(21, 0, 79, diveSettings); + model = new BuhlmannZHL16C(); + }); + + it('should calculate time to fly for surface conditions', () => { + // At surface with no dive, should be 0 time to fly + const timeToFly = model.getTimeToFly(); + expect(timeToFly).toBe(0); + }); + + it('should calculate time to fly after a dive', () => { + // Simulate a dive to 30m for 30 minutes + const segment = new DiveSegment(0, 1800, 'Test', 'Test dive', 30, 30, air, 'test', diveSettings); + model.calculateForSegment(segment); + + // Should have some time to fly after nitrogen loading + const timeToFly = model.getTimeToFly(); + expect(timeToFly).toBeDefined(); + expect(timeToFly).toBeGreaterThan(0); + + // Should be reasonable time (less than 24 hours for a moderate dive) + expect(timeToFly).toBeLessThan(24 * 3600); + }); + + it('should calculate longer time to fly for deeper/longer dives', () => { + // Simulate two different dives + const shallowDive = new DiveSegment(0, 1800, 'Shallow', 'Shallow dive', 20, 20, air, 'test', diveSettings); + const deepDive = new DiveSegment(0, 1800, 'Deep', 'Deep dive', 40, 40, air, 'test', diveSettings); + + const shallowModel = new BuhlmannZHL16C(); + const deepModel = new BuhlmannZHL16C(); + + shallowModel.calculateForSegment(shallowDive); + deepModel.calculateForSegment(deepDive); + + const shallowTimeToFly = shallowModel.getTimeToFly(); + const deepTimeToFly = deepModel.getTimeToFly(); + + expect(shallowTimeToFly).toBeDefined(); + expect(deepTimeToFly).toBeDefined(); + + if (shallowTimeToFly !== undefined && deepTimeToFly !== undefined) { + expect(deepTimeToFly).toBeGreaterThan(shallowTimeToFly); + } + }); + + it('should use custom flying threshold', () => { + // Simulate a dive to 30m for 30 minutes + const segment = new DiveSegment(0, 1800, 'Test', 'Test dive', 30, 30, air, 'test', diveSettings); + model.calculateForSegment(segment); + + // Compare different thresholds + const conservativeTime = model.getTimeToFly(0.80); // More conservative + const standardTime = model.getTimeToFly(0.869); // Standard (110% of ambient) + const liberalTime = model.getTimeToFly(0.95); // More liberal + + expect(conservativeTime).toBeDefined(); + expect(standardTime).toBeDefined(); + expect(liberalTime).toBeDefined(); + + if (conservativeTime !== undefined && standardTime !== undefined && liberalTime !== undefined) { + expect(conservativeTime).toBeGreaterThan(standardTime); + expect(standardTime).toBeGreaterThan(liberalTime); + } + }); + + it('should return controlling tissue time', () => { + // Simulate a dive that loads multiple tissues + const segment = new DiveSegment(0, 3600, 'Test', 'Long dive', 25, 25, air, 'test', diveSettings); + model.calculateForSegment(segment); + + const timeToFly = model.getTimeToFly(); + + // The result should be reasonable for a long shallow dive + expect(timeToFly).toBeDefined(); + expect(timeToFly).toBeGreaterThan(0); + + // Should be controlled by slower tissues for longer dives + expect(timeToFly).toBeGreaterThan(3600); // More than 1 hour for a 1-hour dive + }); +}); \ No newline at end of file diff --git a/src/src/app/dive-planner-service/BuhlmannZHL16C.ts b/src/src/app/dive-planner-service/BuhlmannZHL16C.ts index 5765fee7..ace926c6 100644 --- a/src/src/app/dive-planner-service/BuhlmannZHL16C.ts +++ b/src/src/app/dive-planner-service/BuhlmannZHL16C.ts @@ -62,4 +62,13 @@ export class BuhlmannZHL16C { getTissuePHe(time: number, tissue: number): number { return this.tissues[tissue - 1].getPHe(time); } + + getTimeToFly(flyingN2Threshold: number = 0.869): number | undefined { + const tissueTimes = this.tissues.map(t => t.getTimeToFly(flyingN2Threshold)); + const validTimes = tissueTimes.filter(x => x !== undefined) as number[]; + + if (validTimes.length === 0) return undefined; + + return Math.max(...validTimes); + } } diff --git a/src/src/app/dive-planner-service/DivePlannerService.spec.ts b/src/src/app/dive-planner-service/DivePlannerService.spec.ts index e4dc315c..1b37a682 100644 --- a/src/src/app/dive-planner-service/DivePlannerService.spec.ts +++ b/src/src/app/dive-planner-service/DivePlannerService.spec.ts @@ -42,6 +42,10 @@ describe('DivePlannerService', () => { expect(svc.getHypoxicError().duration).toBe(0); expect(svc.getMaxDepth()).toBe(0); expect(svc.getPO2Error().duration).toBe(0); + + // Test time to fly at surface - should be 0 + const timeToFly = svc.getTimeToFly(); + expect(timeToFly).toBe(0); }); it('30m for 25 mins on nitrox 32', () => { @@ -78,6 +82,12 @@ describe('DivePlannerService', () => { expect(svc.getMaxDepth()).toBe(30); expect(svc.getPO2Error().duration).toBe(0); + // Test time to fly - should be reasonable for a recreational dive + const timeToFly = svc.getTimeToFly(); + expect(timeToFly).toBeDefined(); + expect(timeToFly).toBeGreaterThan(0); + expect(timeToFly).toBeLessThan(24 * 3600); // Less than 24 hours + expect(svc.getNoDecoLimit(25, air, 0)).toBe(518); expect(svc.getOptimalDecoGas(25).oxygen).toBe(45); expect(svc.getOptimalDecoGas(25).helium).toBe(0); @@ -138,6 +148,11 @@ describe('DivePlannerService', () => { expect(svc.getHypoxicError().duration).toBe(6); expect(svc.getMaxDepth()).toBe(100); expect(svc.getPO2Error().duration).toBe(708); + + // Test time to fly - should be significant after a deep technical dive + const timeToFly = svc.getTimeToFly(); + expect(timeToFly).toBeDefined(); + expect(timeToFly).toBeGreaterThan(0); }); it('NDL accounts for on-gassing during ascent', () => { diff --git a/src/src/app/dive-planner-service/DivePlannerService.ts b/src/src/app/dive-planner-service/DivePlannerService.ts index f053949b..c7a3cb57 100644 --- a/src/src/app/dive-planner-service/DivePlannerService.ts +++ b/src/src/app/dive-planner-service/DivePlannerService.ts @@ -148,6 +148,10 @@ export class DivePlannerService { return this.diveProfile.getENDError(); } + getTimeToFly(flyingN2Threshold: number = 0.869): number | undefined { + return this.diveProfile.getTimeToFly(flyingN2Threshold); + } + getPO2WarningMessage(pO2: number): string | undefined { if (pO2 > this.settings.workingPO2Maximum && pO2 <= this.settings.decoPO2Maximum) return `Oxygen partial pressure should only go above ${this.settings.workingPO2Maximum} during deco stops`; diff --git a/src/src/app/dive-planner-service/DiveProfile.ts b/src/src/app/dive-planner-service/DiveProfile.ts index d203309a..6bf6fe49 100644 --- a/src/src/app/dive-planner-service/DiveProfile.ts +++ b/src/src/app/dive-planner-service/DiveProfile.ts @@ -241,6 +241,10 @@ export class DiveProfile { return { end: maxEND, duration }; } + getTimeToFly(flyingN2Threshold: number = 0.869): number | undefined { + return this.algo.getTimeToFly(flyingN2Threshold); + } + getMaxDepth(): number { return Math.max(...this.segments.map(x => x.EndDepth)); } diff --git a/src/src/app/dive-planner-service/Tissue.spec.ts b/src/src/app/dive-planner-service/Tissue.spec.ts new file mode 100644 index 00000000..a495cbb0 --- /dev/null +++ b/src/src/app/dive-planner-service/Tissue.spec.ts @@ -0,0 +1,25 @@ +import { Tissue } from './Tissue'; +import { BreathingGas } from './BreathingGas'; +import { DiveSettingsService } from './DiveSettings.service'; +import { DiveSegment } from './DiveSegment'; + +describe('Tissue Time to Fly', () => { + let diveSettings: DiveSettingsService; + let air: BreathingGas; + + beforeEach(() => { + diveSettings = new DiveSettingsService(); + // Initialize StandardGases to avoid the find error + BreathingGas.StandardGases = []; + air = BreathingGas.create(21, 0, 79, diveSettings); + }); + + it('should return 0 for surface conditions', () => { + // Test tissue compartment 1 (fastest) - 5 minute N2 half-life + const tissue = new Tissue(1, 5, 1.1696, 0.5578, 1.51, 1.7474, 0.4245); + + // At surface, should be 0 time to fly + const timeToFly = tissue.getTimeToFly(); + expect(timeToFly).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/src/app/dive-planner-service/Tissue.ts b/src/src/app/dive-planner-service/Tissue.ts index 5fdd1d74..855f7158 100644 --- a/src/src/app/dive-planner-service/Tissue.ts +++ b/src/src/app/dive-planner-service/Tissue.ts @@ -101,6 +101,33 @@ export class Tissue { return Math.floor(time); } + getTimeToFly(flyingN2Threshold: number = 0.869): number | undefined { + const currentTime = this.tissueByTime.size - 1; + const currentPN2 = this.getPN2(currentTime); + const currentPHe = this.getPHe(currentTime); + + // If we're already at safe levels, no waiting time needed + if (currentPN2 <= flyingN2Threshold && currentPHe <= this.ENVIRONMENT_PHE + 0.001) { + return 0; + } + + // Surface gas pressures + const surfacePN2 = this.ENVIRONMENT_PN2; + const surfacePHe = this.ENVIRONMENT_PHE; + + // Simple iterative approach - check every minute + for (let time = 1; time <= this.MAX_NDL; time++) { + const newPN2 = currentPN2 + this.getPN2DeltaByTime(currentPN2, surfacePN2, time); + const newPHe = currentPHe + this.getPHeDeltaByTime(currentPHe, surfacePHe, time); + + if (newPN2 <= flyingN2Threshold && newPHe <= this.ENVIRONMENT_PHE + 0.001) { + return time; + } + } + + return undefined; // If it takes longer than MAX_NDL + } + getPN2(time: number): number { return this.getTissueByTime(time).PN2; } diff --git a/src/src/app/dive-summary/dive-summary.component.html b/src/src/app/dive-summary/dive-summary.component.html index 87b16292..f2ceb9ca 100644 --- a/src/src/app/dive-summary/dive-summary.component.html +++ b/src/src/app/dive-summary/dive-summary.component.html @@ -11,4 +11,8 @@ Average Depth: {{ divePlanner.getAverageDepth() | number: '1.0-0' }}m +
+ Time to Fly: + {{ getTimeToFlyFormatted() }} +
diff --git a/src/src/app/dive-summary/dive-summary.component.spec.ts b/src/src/app/dive-summary/dive-summary.component.spec.ts index e69de29b..499235fc 100644 --- a/src/src/app/dive-summary/dive-summary.component.spec.ts +++ b/src/src/app/dive-summary/dive-summary.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DiveSummaryComponent } from './dive-summary.component'; +import { DivePlannerService } from '../dive-planner-service/DivePlannerService'; +import { HumanDurationPipe } from '../pipes/human-duration.pipe'; + +describe('DiveSummaryComponent', () => { + let component: DiveSummaryComponent; + let fixture: ComponentFixture; + let mockDivePlannerService: jasmine.SpyObj; + + beforeEach(async () => { + mockDivePlannerService = jasmine.createSpyObj('DivePlannerService', ['getTimeToFly', 'getDiveDuration', 'getMaxDepth', 'getAverageDepth']); + + await TestBed.configureTestingModule({ + declarations: [DiveSummaryComponent, HumanDurationPipe], + providers: [ + { provide: DivePlannerService, useValue: mockDivePlannerService } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(DiveSummaryComponent); + component = fixture.componentInstance; + + // Set up default mock returns + mockDivePlannerService.getDiveDuration.and.returnValue(1800); + mockDivePlannerService.getMaxDepth.and.returnValue(30); + mockDivePlannerService.getAverageDepth.and.returnValue(25); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should format time to fly correctly for no waiting time', () => { + mockDivePlannerService.getTimeToFly.and.returnValue(0); + expect(component.getTimeToFlyFormatted()).toBe('None'); + }); + + it('should format time to fly correctly for minutes only', () => { + mockDivePlannerService.getTimeToFly.and.returnValue(1800); // 30 minutes + expect(component.getTimeToFlyFormatted()).toBe('30 min'); + }); + + it('should format time to fly correctly for hours only', () => { + mockDivePlannerService.getTimeToFly.and.returnValue(7200); // 2 hours + expect(component.getTimeToFlyFormatted()).toBe('2 hr'); + }); + + it('should format time to fly correctly for hours and minutes', () => { + mockDivePlannerService.getTimeToFly.and.returnValue(9000); // 2 hours 30 minutes + expect(component.getTimeToFlyFormatted()).toBe('2 hr 30 min'); + }); + + it('should format time to fly correctly for undefined (> 5 hours)', () => { + mockDivePlannerService.getTimeToFly.and.returnValue(undefined); + expect(component.getTimeToFlyFormatted()).toBe('> 5 hours'); + }); +}); diff --git a/src/src/app/dive-summary/dive-summary.component.ts b/src/src/app/dive-summary/dive-summary.component.ts index 1710897b..9f749363 100644 --- a/src/src/app/dive-summary/dive-summary.component.ts +++ b/src/src/app/dive-summary/dive-summary.component.ts @@ -8,4 +8,27 @@ import { DivePlannerService } from '../dive-planner-service/DivePlannerService'; }) export class DiveSummaryComponent { constructor(public divePlanner: DivePlannerService) {} + + getTimeToFlyFormatted(): string { + const timeToFly = this.divePlanner.getTimeToFly(); + + if (timeToFly === undefined) { + return '> 5 hours'; + } + + if (timeToFly === 0) { + return 'None'; + } + + const hours = Math.floor(timeToFly / 3600); + const minutes = Math.floor((timeToFly % 3600) / 60); + + if (hours === 0) { + return `${minutes} min`; + } else if (minutes === 0) { + return `${hours} hr`; + } else { + return `${hours} hr ${minutes} min`; + } + } }