From 5d99b8033eab79cae7b567d77341bb900a494592 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 7 Sep 2026 23:55:06 -0700 Subject: [PATCH] Move the store's write path into the shared framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape a nav-data store takes was described in one place, inside the app's importer, and is about to be needed in a second: the macOS tool will build a store ahead of time so the app can download one rather than spend minutes assembling it. Two implementations of that shape would drift, and the difference between them would surface in a cockpit rather than in a build. NavDataStoreWriter now owns it, and the app's importer is one of its two callers. NavDataLoader keeps what is actually its own — the download, the LZMA decode, the state stream, the Sentry transaction and the signposts — and stops being a model actor, since it no longer holds a context. The executor constraints its documentation described belong to the writer, and moved with the code they constrain. Progress crosses between them through a stream rather than a callback. A callback would have to run back in the loader's isolation from an actor whose executor the write occupies, which is the deadlock the loader's own download path already avoids the same way. SF50 Shared links swift-algorithms, which the write path batches with and which only the app target had. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01471376Uhug2TYZN8RDFGtq --- SF50 Shared/Store/NavDataStoreWriter.swift | 294 ++++++++++++++++++ SF50 TOLD.xcodeproj/project.pbxproj | 8 + .../Loaders/NavDataLoader/NavDataLoader.swift | 285 ++--------------- 3 files changed, 328 insertions(+), 259 deletions(-) create mode 100644 SF50 Shared/Store/NavDataStoreWriter.swift diff --git a/SF50 Shared/Store/NavDataStoreWriter.swift b/SF50 Shared/Store/NavDataStoreWriter.swift new file mode 100644 index 0000000..84c1d03 --- /dev/null +++ b/SF50 Shared/Store/NavDataStoreWriter.swift @@ -0,0 +1,294 @@ +public import Foundation +public import SwiftData + +import Algorithms +import CoreLocation +import NavData + +/// Writes a decoded navigation dataset into a SwiftData store. +/// +/// This is the only implementation of the shape a nav-data store takes, and both things that +/// produce one use it: the app, importing a downloaded property list, and the macOS tool that +/// publishes a store built ahead of time. Two implementations would drift, and the difference +/// between them would only show up in a cockpit. +/// +/// The store it writes is expected to be empty. A dataset is replaced by writing a new generation +/// beside the one in use, never by clearing rows out of it. +/// +/// ## Executor Constraints +/// +/// A `@ModelActor`'s serial executor is its `NSManagedObjectContext`'s dispatch queue, and SwiftData +/// enqueues jobs onto that executor with `-[NSManagedObjectContext performBlockAndWait:]`. +/// Enqueueing therefore blocks the *calling* thread until the executor is free, so every caller — +/// the main actor included — stalls for as long as this actor stays busy. Persistence work is +/// split into bounded batches separated by `await` so it never occupies the executor without +/// suspending. +@ModelActor +public actor NavDataStoreWriter { + /// Maximum number of rows written per save. + /// + /// Each save holds the store's write lock for its full commit, so the import is split into + /// bounded transactions rather than one that spans the whole dataset. The bound is generous + /// because the writer works through its own persistent store coordinator against a WAL-journaled + /// store, where readers are never blocked by a write in flight; the only cost a long transaction + /// imposes is on this actor's own executor, which a smaller bound would trade for many more + /// commits. + private static let saveBatchRowLimit = 10000 + + private var navaidLookup: [String: Navaid] = [:] + + /// Writes an entire dataset, reporting how far through it has got. + /// + /// Progress is pushed into a stream rather than handed to a callback, because a callback would + /// have to cross back into the caller's isolation on an actor whose executor this work occupies. + /// + /// - Parameters: + /// - data: The decoded dataset to write. + /// - continuation: Yielded a fraction from 0 to 1 as airports and obstacles are written, and + /// finished when the dataset is fully written. + public func write( + _ data: AirportDataCodable, + reportingTo continuation: AsyncStream.Continuation + ) async throws { + defer { continuation.finish() } + let progress = { continuation.yield($0) } + + // Navaids come first so they are available for leg relationships. + try await loadNavaids(data.navaids ?? []) + + let totalItems = data.airports.count + data.obstacles.count + try await loadAirports(data.airports) { airportsProcessed in + progress(Float(airportsProcessed) / Float(totalItems)) + } + + let airportCount = data.airports.count + try await loadObstacles(data.obstacles) { obstaclesProcessed in + progress(Float(airportCount + obstaclesProcessed) / Float(totalItems)) + } + + try writeCycles(data.cycles) + } + + private func writeCycles(_ cycles: AirportDataCodable.DataCycles) throws { + insertCycle(cycles.nasr, source: .nasr) + insertCycle(cycles.cifp, source: .cifp) + insertCycle(cycles.dof, source: .dof) + try modelContext.save() + } + + private func insertCycle( + _ info: AirportDataCodable.CycleInfo?, + source: CycleDataSource + ) { + guard let info else { return } + modelContext.insert( + Cycle( + dataSource: source, + name: info.name, + effective: info.effective, + expires: info.expires + ) + ) + } + + private func loadNavaids(_ navaids: [NavaidCodable]) async throws { + navaidLookup.removeAll() + for batch in navaids.chunks(ofCount: Self.saveBatchRowLimit) { + for navaidData in batch { + let navaid = Navaid( + identifier: navaidData.identifier, + icaoRegion: navaidData.icaoRegion, + type: navaidData.type, + latitude: .init(value: navaidData.latitude, unit: .degrees), + longitude: .init(value: navaidData.longitude, unit: .degrees), + elevation: navaidData.elevationFt.map { .init(value: $0, unit: .feet) } + ) + modelContext.insert(navaid) + navaidLookup["\(navaidData.identifier):\(navaidData.icaoRegion)"] = navaid + } + try modelContext.save() + await Task.yield() + } + } + + /// Inserts an airport and its runways, procedures, segments, and legs. + /// + /// - Returns: The number of rows inserted, so callers can bound save batches + /// by row count. + private func lookupNavaid(_ legData: AirportDataCodable.LegCodable) -> Navaid? { + guard let id = legData.recommendedNavaidIdentifier, + let icao = legData.recommendedNavaidICAO + else { return nil } + return navaidLookup["\(id):\(icao)"] + } + + private func loadAirports( + _ airports: [AirportDataCodable.AirportCodable], + progress: (Int) -> Void + ) async throws { + var processed = 0, + rowsSinceLastSave = 0 + + // An airport carries nested runway/procedure/segment/leg inserts, so + // batches are bounded by total inserted rows rather than airport count. + for airport in airports { + rowsSinceLastSave += addAirport(airport) + processed += 1 + + if rowsSinceLastSave >= Self.saveBatchRowLimit { + try modelContext.save() + rowsSinceLastSave = 0 + progress(processed) + await Task.yield() + } + } + + if modelContext.hasChanges { + try modelContext.save() + progress(processed) + } + } + + private func loadObstacles( + _ obstacles: [AirportDataCodable.ObstacleCodable], + progress: (Int) -> Void + ) async throws { + var processed = 0 + + for batch in obstacles.chunks(ofCount: Self.saveBatchRowLimit) { + for obstacleData in batch { + let obstacle = Obstacle( + heightMSL: .init(value: Double(obstacleData.heightFtMSL), unit: .feet), + latitude: .init(value: obstacleData.latitude, unit: .degrees), + longitude: .init(value: obstacleData.longitude, unit: .degrees) + ) + modelContext.insert(obstacle) + } + + try modelContext.save() + + processed += batch.count + progress(processed) + await Task.yield() + } + } + + private func addAirport(_ airport: AirportDataCodable.AirportCodable) -> Int { + let dataSource = DataSource(rawValue: airport.dataSource) ?? .NASR + let timeZone = airport.timeZone.flatMap { TimeZone(identifier: $0) } + + let record = Airport( + recordID: airport.recordID, + locationID: airport.locationID, + ICAO_ID: airport.ICAO_ID, + name: airport.name, + city: airport.city, + dataSource: dataSource, + latitude: .init(value: airport.latitude, unit: .degrees), + longitude: .init(value: airport.longitude, unit: .degrees), + elevation: .init(value: airport.elevation, unit: .meters), + variation: .init(value: airport.variation, unit: .degrees), + timeZone: timeZone + ) + + // Create a map to find reciprocal runways + var runwayMap = [String: Runway]() + + for runwayData in airport.runways { + // Create threshold coordinate if both lat/lon are available + var thresholdCoordinate: CLLocationCoordinate2D? + if let lat = runwayData.thresholdLatitude, + let lon = runwayData.thresholdLongitude + { + thresholdCoordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + let runway = Runway( + name: runwayData.name, + elevation: runwayData.elevation.map { .init(value: $0, unit: .meters) }, + trueHeading: .init(value: runwayData.trueHeading, unit: .degrees), + gradient: runwayData.gradient, + length: .init(value: runwayData.length, unit: .meters), + width: runwayData.width.map { .init(value: $0, unit: .meters) }, + takeoffRun: runwayData.takeoffRun.map { .init(value: $0, unit: .meters) }, + takeoffDistance: runwayData.takeoffDistance.map { .init(value: $0, unit: .meters) }, + landingDistance: runwayData.landingDistance.map { .init(value: $0, unit: .meters) }, + surfaceType: runwayData.decodedSurfaceType, + thresholdCoordinate: thresholdCoordinate, + thresholdCrossingHeight: runwayData.thresholdCrossingHeight.map { + .init(value: $0, unit: .meters) + }, + glidepathAngle: runwayData.glidepathAngle.map { .init(value: $0, unit: .degrees) }, + displacedThresholdDistance: runwayData.displacedThresholdDistance.map { + .init(value: $0, unit: .meters) + }, + airport: record + ) + runwayMap[runwayData.name] = runway + } + + // Only insert the airport and runways if we have runways + guard !runwayMap.isEmpty else { return 0 } + + var insertedRows = 1 + runwayMap.count + + modelContext.insert(record) + for runway in runwayMap.values { + modelContext.insert(runway) + } + + // Set reciprocal runway names + for runwayData in airport.runways { + if let runway = runwayMap[runwayData.name] { + runway.reciprocalName = runwayData.reciprocalName + } + } + + // Load procedures (departures and approaches) + for procedureData in airport.procedures ?? [] { + let procedureType = Procedure.ProcedureType(rawValue: procedureData.type) ?? .departure + let procedure = Procedure( + type: procedureType, + identifier: procedureData.identifier, + name: procedureData.name, + runwayName: procedureData.runwayName, + requiredClimbGradientFtPerNM: procedureData.requiredClimbGradientFtPerNM, + airport: record + ) + modelContext.insert(procedure) + insertedRows += 1 + + for segmentData in procedureData.segments ?? [] { + let segment = ProcedureSegment( + runwayNames: segmentData.runwayNames ?? [], + procedure: procedure + ) + modelContext.insert(segment) + insertedRows += 1 + + for (index, legData) in segmentData.legs.enumerated() { + let altitudeRestriction = legData.altitudeRestriction.map { + AltitudeRestriction(from: $0) + } + let navaid = lookupNavaid(legData) + let leg = Leg( + identifier: legData.identifier, + latitude: legData.latitude.map { .init(value: $0, unit: .degrees) }, + longitude: legData.longitude.map { .init(value: $0, unit: .degrees) }, + altitudeRestriction: altitudeRestriction, + legType: legData.legType, + sequenceIndex: index, + segment: segment, + navaid: navaid, + dmeDistance: legData.dmeDistanceNM.map { .init(value: $0, unit: .nauticalMiles) }, + theta: legData.thetaDeg.map { .init(value: $0, unit: .degrees) } + ) + modelContext.insert(leg) + insertedRows += 1 + } + } + } + + return insertedRows + } +} diff --git a/SF50 TOLD.xcodeproj/project.pbxproj b/SF50 TOLD.xcodeproj/project.pbxproj index d9c70e3..fe56ef1 100644 --- a/SF50 TOLD.xcodeproj/project.pbxproj +++ b/SF50 TOLD.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 0128374E2E5B8BB8002195A0 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0128374D2E5B8BB8002195A0 /* SwiftUI.framework */; }; 0128375F2E5B8BB9002195A0 /* SF50 RunwaysExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0128374A2E5B8BB8002195A0 /* SF50 RunwaysExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 012B1E462DE2F272000D9ADC /* SF50_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 012B1E3B2DE2F272000D9ADC /* SF50_Shared.framework */; }; + 94CC1E014886B23174174CD9 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 02BD3D6AFE77DA2CAC646D3B /* Algorithms */; }; 012B1E502DE2F272000D9ADC /* SF50_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 012B1E3B2DE2F272000D9ADC /* SF50_Shared.framework */; }; 012B1E512DE2F272000D9ADC /* SF50_Shared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 012B1E3B2DE2F272000D9ADC /* SF50_Shared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0163BC742DDEE9D4008FEE8F /* SF50_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 012B1E3B2DE2F272000D9ADC /* SF50_Shared.framework */; }; @@ -307,6 +308,7 @@ 012B1E382DE2F272000D9ADC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( + 94CC1E014886B23174174CD9 /* Algorithms in Frameworks */, 01AC00212FE0000000000021 /* MeasurementKitLocation in Frameworks */, 01AC00202FE0000000000020 /* MeasurementKit in Frameworks */, 018ED7A62F345C2000D084B4 /* SwiftMETAR in Frameworks */, @@ -504,6 +506,7 @@ 01A1014E2E93C12E0032AB29 /* Gzip */, 018ED7A52F345C2000D084B4 /* SwiftMETAR */, EEB6B6DD4229EEB6363A2AA1 /* NavData */, + 02BD3D6AFE77DA2CAC646D3B /* Algorithms */, ); productName = "SF50 Shared"; productReference = 012B1E3B2DE2F272000D9ADC /* SF50_Shared.framework */; @@ -1951,6 +1954,11 @@ package = 01714C7E2F234A7E003DADCF /* XCRemoteSwiftPackageReference "swift-algorithms" */; productName = Algorithms; }; + 02BD3D6AFE77DA2CAC646D3B /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 01714C7E2F234A7E003DADCF /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; 01714CB42F24587D003DADCF /* ZIPFoundation */ = { isa = XCSwiftPackageProductDependency; package = 01714CB32F24587D003DADCF /* XCRemoteSwiftPackageReference "ZIPFoundation" */; diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift index 1b0c6b6..86cb68b 100644 --- a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift @@ -97,23 +97,10 @@ import SwiftNASR /// /// - ``NavDataLoaderViewModel`` /// - ``State`` -@ModelActor actor NavDataLoader { private static let dataURLTemplate = "https://github.com/SF50-TOLD/NavDataDistribution/releases/download/%1$@/%1$@.plist.lzma" - /// Maximum number of rows written per save. - /// - /// Each save holds the store's write lock for its full commit, so the import - /// is split into bounded transactions rather than one that spans the whole - /// dataset. The bound is generous because the importer writes through its - /// own persistent store coordinator against a WAL-journaled store, where - /// readers — the main context and the widget process — are never blocked by - /// a write in flight; the only cost a long transaction imposes is on this - /// actor's own executor, which a smaller bound would trade for many more - /// commits. - private static let saveBatchRowLimit = 10000 - /// Smallest change in download progress worth pushing to consumers. /// /// The session reports progress once per received chunk, which is far finer @@ -134,13 +121,21 @@ actor NavDataLoader { category: "NavDataLoader" ) - private var navaidLookup: [String: SF50_Shared.Navaid] = [:] + private let writer: NavDataStoreWriter private var stateContinuation: AsyncStream.Continuation? private var dataURL: URL { URL(string: String(format: Self.dataURLTemplate, "\(Cycle.effective)"))! } + /// Creates a loader writing into `modelContainer`. + /// + /// - Parameter modelContainer: A container whose nav-data store accepts writes, holding the + /// generation this import is producing. + init(modelContainer: ModelContainer) { + writer = .init(modelContainer: modelContainer) + } + /// Inflates the LZMA payload and decodes it, off this actor's executor. /// /// Both steps are CPU-bound and touch no `modelContext`, so they run on the @@ -214,29 +209,8 @@ actor NavDataLoader { state = .extracting(progress: nil) let nasr = try await timing("decode") { try await Self.decompress(fileAt: payload) } - // Load navaids first so they're available for leg relationships - try await timing("navaids") { try await loadNavaids(nasr.navaids ?? []) } - - // Combined progress tracking across both loading phases state = .loading(progress: 0) - let totalItems = nasr.airports.count + nasr.obstacles.count - - try await timing("airports") { - try await loadAirports(nasr.airports) { airportsProcessed in - self.state = .loading(progress: Float(airportsProcessed) / Float(totalItems)) - } - } - - let airportCount = nasr.airports.count - try await timing("obstacles") { - try await loadObstacles(nasr.obstacles) { obstaclesProcessed in - self.state = .loading( - progress: Float(airportCount + obstaclesProcessed) / Float(totalItems) - ) - } - } - - try writeCycles(nasr.cycles) + try await timing("write") { try await write(nasr) } state = .finished return LoadResult( @@ -272,34 +246,28 @@ actor NavDataLoader { return result } - private func writeCycles(_ cycles: AirportDataCodable.DataCycles) throws { - insertCycle(cycles.nasr, source: .nasr) - insertCycle(cycles.cifp, source: .cifp) - insertCycle(cycles.dof, source: .dof) - try modelContext.save() - } - - private func insertCycle( - _ info: AirportDataCodable.CycleInfo?, - source: CycleDataSource - ) { - guard let info else { return } - modelContext.insert( - Cycle( - dataSource: source, - name: info.name, - effective: info.effective, - expires: info.expires - ) - ) - } - private func reportDownloadProgress(_ progress: Float) { guard case .downloading(let reported) = state else { return } if let reported, abs(progress - reported) < Self.progressReportingStep { return } state = .downloading(progress: progress) } + /// Writes the decoded dataset, mirroring the writer's progress onto this actor's state. + /// + /// The write runs as a child task so this actor stays free to drain its progress; the stream + /// closes when the write settles, ending the loop. + private func write(_ data: AirportDataCodable) async throws { + let (progressUpdates, continuation) = AsyncStream.makeStream( + of: Float.self, + bufferingPolicy: .bufferingNewest(1) + ) + + async let written: Void = writer.write(data, reportingTo: continuation) + for await completed in progressUpdates { state = .loading(progress: completed) } + + try await written + } + /// Downloads the payload, forwarding the transfer's progress to `progress`. /// /// The transfer runs as a child task so this actor stays free to drain its @@ -316,207 +284,6 @@ actor NavDataLoader { return try await downloaded } - private func loadAirports( - _ airports: [AirportDataCodable.AirportCodable], - progress: (Int) -> Void - ) async throws { - var processed = 0, - rowsSinceLastSave = 0 - - // An airport carries nested runway/procedure/segment/leg inserts, so - // batches are bounded by total inserted rows rather than airport count. - for airport in airports { - rowsSinceLastSave += addAirport(airport) - processed += 1 - - if rowsSinceLastSave >= Self.saveBatchRowLimit { - try modelContext.save() - rowsSinceLastSave = 0 - progress(processed) - await Task.yield() - } - } - - if modelContext.hasChanges { - try modelContext.save() - progress(processed) - } - } - - private func loadObstacles( - _ obstacles: [AirportDataCodable.ObstacleCodable], - progress: (Int) -> Void - ) async throws { - var processed = 0 - - for batch in obstacles.chunks(ofCount: Self.saveBatchRowLimit) { - for obstacleData in batch { - let obstacle = Obstacle( - heightMSL: .init(value: Double(obstacleData.heightFtMSL), unit: .feet), - latitude: .init(value: obstacleData.latitude, unit: .degrees), - longitude: .init(value: obstacleData.longitude, unit: .degrees) - ) - modelContext.insert(obstacle) - } - - try modelContext.save() - - processed += batch.count - progress(processed) - await Task.yield() - } - } - - private func lookupNavaid(_ legData: AirportDataCodable.LegCodable) -> SF50_Shared.Navaid? { - guard let id = legData.recommendedNavaidIdentifier, - let icao = legData.recommendedNavaidICAO - else { return nil } - return navaidLookup["\(id):\(icao)"] - } - - private func loadNavaids(_ navaids: [NavaidCodable]) async throws { - navaidLookup.removeAll() - for batch in navaids.chunks(ofCount: Self.saveBatchRowLimit) { - for navaidData in batch { - let navaid = SF50_Shared.Navaid( - identifier: navaidData.identifier, - icaoRegion: navaidData.icaoRegion, - type: navaidData.type, - latitude: .init(value: navaidData.latitude, unit: .degrees), - longitude: .init(value: navaidData.longitude, unit: .degrees), - elevation: navaidData.elevationFt.map { .init(value: $0, unit: .feet) } - ) - modelContext.insert(navaid) - navaidLookup["\(navaidData.identifier):\(navaidData.icaoRegion)"] = navaid - } - try modelContext.save() - await Task.yield() - } - } - - /// Inserts an airport and its runways, procedures, segments, and legs. - /// - /// - Returns: The number of rows inserted, so callers can bound save batches - /// by row count. - private func addAirport(_ airport: AirportDataCodable.AirportCodable) -> Int { - let dataSource = DataSource(rawValue: airport.dataSource) ?? .NASR - let timeZone = airport.timeZone.flatMap { TimeZone(identifier: $0) } - - let record = Airport( - recordID: airport.recordID, - locationID: airport.locationID, - ICAO_ID: airport.ICAO_ID, - name: airport.name, - city: airport.city, - dataSource: dataSource, - latitude: .init(value: airport.latitude, unit: .degrees), - longitude: .init(value: airport.longitude, unit: .degrees), - elevation: .init(value: airport.elevation, unit: .meters), - variation: .init(value: airport.variation, unit: .degrees), - timeZone: timeZone - ) - - // Create a map to find reciprocal runways - var runwayMap = [String: SF50_Shared.Runway]() - - for runwayData in airport.runways { - // Create threshold coordinate if both lat/lon are available - var thresholdCoordinate: CLLocationCoordinate2D? - if let lat = runwayData.thresholdLatitude, - let lon = runwayData.thresholdLongitude - { - thresholdCoordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) - } - - let runway = SF50_Shared.Runway( - name: runwayData.name, - elevation: runwayData.elevation.map { .init(value: $0, unit: .meters) }, - trueHeading: .init(value: runwayData.trueHeading, unit: .degrees), - gradient: runwayData.gradient, - length: .init(value: runwayData.length, unit: .meters), - width: runwayData.width.map { .init(value: $0, unit: .meters) }, - takeoffRun: runwayData.takeoffRun.map { .init(value: $0, unit: .meters) }, - takeoffDistance: runwayData.takeoffDistance.map { .init(value: $0, unit: .meters) }, - landingDistance: runwayData.landingDistance.map { .init(value: $0, unit: .meters) }, - surfaceType: runwayData.decodedSurfaceType, - thresholdCoordinate: thresholdCoordinate, - thresholdCrossingHeight: runwayData.thresholdCrossingHeight.map { - .init(value: $0, unit: .meters) - }, - glidepathAngle: runwayData.glidepathAngle.map { .init(value: $0, unit: .degrees) }, - displacedThresholdDistance: runwayData.displacedThresholdDistance.map { - .init(value: $0, unit: .meters) - }, - airport: record - ) - runwayMap[runwayData.name] = runway - } - - // Only insert the airport and runways if we have runways - guard !runwayMap.isEmpty else { return 0 } - - var insertedRows = 1 + runwayMap.count - - modelContext.insert(record) - for runway in runwayMap.values { - modelContext.insert(runway) - } - - // Set reciprocal runway names - for runwayData in airport.runways { - if let runway = runwayMap[runwayData.name] { - runway.reciprocalName = runwayData.reciprocalName - } - } - - // Load procedures (departures and approaches) - for procedureData in airport.procedures ?? [] { - let procedureType = Procedure.ProcedureType(rawValue: procedureData.type) ?? .departure - let procedure = Procedure( - type: procedureType, - identifier: procedureData.identifier, - name: procedureData.name, - runwayName: procedureData.runwayName, - requiredClimbGradientFtPerNM: procedureData.requiredClimbGradientFtPerNM, - airport: record - ) - modelContext.insert(procedure) - insertedRows += 1 - - for segmentData in procedureData.segments ?? [] { - let segment = ProcedureSegment( - runwayNames: segmentData.runwayNames ?? [], - procedure: procedure - ) - modelContext.insert(segment) - insertedRows += 1 - - for (index, legData) in segmentData.legs.enumerated() { - let altitudeRestriction = legData.altitudeRestriction.map { - AltitudeRestriction(from: $0) - } - let navaid = lookupNavaid(legData) - let leg = Leg( - identifier: legData.identifier, - latitude: legData.latitude.map { .init(value: $0, unit: .degrees) }, - longitude: legData.longitude.map { .init(value: $0, unit: .degrees) }, - altitudeRestriction: altitudeRestriction, - legType: legData.legType, - sequenceIndex: index, - segment: segment, - navaid: navaid, - dmeDistance: legData.dmeDistanceNM.map { .init(value: $0, unit: .nauticalMiles) }, - theta: legData.thetaDeg.map { .init(value: $0, unit: .degrees) } - ) - modelContext.insert(leg) - insertedRows += 1 - } - } - } - - return insertedRows - } - /// Current state of the loading process. /// /// ## Cases