Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions backend/lib/services/zone_ingest_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ class ZoneIngestService {
final String? aipZonesPath;

/// Merges bundled, MADHEL, OpenAIP, and AIP-parsed zones, writes
/// [outputPath], bumps version. AIP zones take highest precedence so their
/// exact polygon boundaries win over any same-id circle approximations.
/// [outputPath], bumps version. Same-id duplicates collapse with AIP polygons
/// winning over circle approximations; cross-source AIP+OpenAIP pairs then
/// collapse via [ZoneMerger] to OpenAIP geometry with AIP authority.
Future<String> ingestAndPublish({required String outputPath}) async {
final bundledZones = await _bundled.fetchZones();
final liveMadhel = await _safeMadhel();
Expand All @@ -59,7 +60,11 @@ class ZoneIngestService {
merged[zone.id] = zone;
}

final features = merged.values.map(_toFeature).toList();
// Collapse cross-source AIP+OpenAIP pairs (same rule as the runtime
// repository) so each real zone is published once with OpenAIP geometry
// and AIP authority.
final collapsed = ZoneMerger.merge(merged.values.toList());
final features = collapsed.map(_toFeature).toList();

final version = DateTime.now().toUtc().toIso8601String();
final collection = {
Expand Down
14 changes: 9 additions & 5 deletions packages/flight_rules_repository/lib/src/zone_deduplicator.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'package:zones_api_client/zones_api_client.dart';

/// Removes literal duplicate zone records after merging feeds.
/// Removes duplicate zone records after merging feeds.
///
/// Only collapses entries that share the same [ZoneData.id]. Geographic
/// containment (a smaller zone inside a larger one) is never treated as a
/// duplicate.
/// Two passes: first collapse literal [ZoneData.id] duplicates (the
/// highest-priority source wins), then delegate to [ZoneMerger] to collapse
/// cross-source AIP+OpenAIP pairs that describe the same airspace under
/// different ids. Geographic containment (a smaller zone inside a larger one)
/// is never treated as a duplicate.
abstract final class ZoneDeduplicator {
static List<ZoneData> dedupe(List<ZoneData> zones) {
if (zones.length < 2) return zones;
Expand All @@ -18,7 +20,9 @@ abstract final class ZoneDeduplicator {
}
}

return zones.map((zone) => zone.id).toSet().map((id) => byId[id]!).toList();
final byIdDeduped =
zones.map((zone) => zone.id).toSet().map((id) => byId[id]!).toList();
return ZoneMerger.merge(byIdDeduped);
}

/// When two records share an id, the higher-priority source wins. An active
Expand Down
103 changes: 103 additions & 0 deletions packages/zones_api_client/lib/src/zone_merger.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import 'package:zones_api_client/src/models/zone_data.dart';
import 'package:zones_api_client/src/zone_identity.dart';
import 'package:zones_api_client/src/zone_source_ids.dart';

/// Collapses zones that two sources describe as the same real airspace.
///
/// ANAC AIP is the authoritative **inventory**; OpenAIP is the **geometry**
/// baseline. When an OpenAIP zone and an AIP zone share a
/// [ZoneIdentity.matchKey], they should render **once**: OpenAIP's vertex-exact
/// geometry carrying AIP-confirmed identity, category, permissions, and
/// vertical limits (`source = openaip`, `confirmedBy = aip`).
///
/// Safety contract:
/// - A `matchKey` **miss** (no cross-source partner, or `null` key) keeps the
/// zone unchanged — a double-render is safer than a dropped authoritative
/// zone (the validator flags the overlap).
/// - A `matchKey` **collision between two distinct designators** is a
/// `matchKey` bug, caught by a test over the known designators — not guarded
/// here at runtime.
///
/// Both the backend `ZoneIngestService` merge step and the repository
/// `ZoneDeduplicator` delegate here so the merge rule lives in exactly one
/// place.
abstract final class ZoneMerger {
/// Returns [zones] with each AIP+OpenAIP `matchKey` pair collapsed to a
/// single merged zone. Input order is otherwise preserved.
static List<ZoneData> merge(List<ZoneData> zones) {
if (zones.length < 2) return zones;

// Group indices by matchKey so we can collapse a group in place while
// leaving keyless / unpaired zones exactly where they were.
final indicesByKey = <String, List<int>>{};
for (var i = 0; i < zones.length; i++) {
final key = ZoneIdentity.matchKey(zones[i].name);
if (key == null) continue;
(indicesByKey[key] ??= []).add(i);
}

// For each collapsible group, record the surviving merged zone at the
// OpenAIP zone's slot and the index to drop (the matched AIP zone).
final mergedAt = <int, ZoneData>{};
final dropped = <int>{};
for (final indices in indicesByKey.values) {
if (indices.length < 2) continue;

int? openIndex; // first OpenAIP zone in the group
int? openPolygonIndex; // first OpenAIP zone carrying a real polygon
int? aipIndex; // first AIP zone in the group
for (final i in indices) {
final zone = zones[i];
if (zone.source == ZoneSourceIds.openaip) {
openIndex ??= i;
if (openPolygonIndex == null && (zone.polygon?.length ?? 0) >= 3) {
openPolygonIndex = i;
}
} else if (zone.source == ZoneSourceIds.aip) {
aipIndex ??= i;
}
}

final geometryIndex = openPolygonIndex ?? openIndex;
if (geometryIndex == null || aipIndex == null) continue;
mergedAt[geometryIndex] = _collapse(
openAip: zones[geometryIndex],
aip: zones[aipIndex],
);
dropped.add(aipIndex);
}

if (mergedAt.isEmpty) return zones;

final result = <ZoneData>[];
for (var i = 0; i < zones.length; i++) {
if (dropped.contains(i)) continue;
result.add(mergedAt[i] ?? zones[i]);
}
return result;
}

/// OpenAIP geometry combined with AIP authority: identity, category,
/// permissions, and vertical limits.
static ZoneData _collapse({
required ZoneData openAip,
required ZoneData aip,
}) =>
ZoneData(
id: openAip.id,
name: aip.name,
categoryId: aip.categoryId,
latitude: openAip.latitude,
longitude: openAip.longitude,
radiusMeters: openAip.radiusMeters,
polygon: openAip.polygon,
allowedPermissionIds: aip.allowedPermissionIds,
details: aip.details,
lowerLimitMetersAgl: aip.lowerLimitMetersAgl,
upperLimitMetersAgl: aip.upperLimitMetersAgl,
lowerLimitMetersMsl: aip.lowerLimitMetersMsl,
upperLimitMetersMsl: aip.upperLimitMetersMsl,
source: ZoneSourceIds.openaip,
confirmedBy: ZoneSourceIds.aip,
);
}
1 change: 1 addition & 0 deletions packages/zones_api_client/lib/zones_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export 'src/openaip_zones_api_client.dart';
export 'src/remote_zones_api_client.dart';
export 'src/text_encoding.dart';
export 'src/zone_identity.dart';
export 'src/zone_merger.dart';
export 'src/zone_source_ids.dart';
export 'src/zone_time.dart';
export 'src/zones_feed_client.dart';
27 changes: 27 additions & 0 deletions packages/zones_api_client/test/zone_identity_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,32 @@ void main() {
expect(ZoneIdentity.matchKey('TMA BAIRES'), 'tma_BAIRES');
expect(ZoneIdentity.matchKey('TMA BAIRES II'), 'tma_BAIRES');
});

test('distinct SAR designators never collapse to the same key', () {
// The merge contract relies on distinct designators having distinct keys.
// A collision here would silently drop one real restricted area.
final keys = <String?>{};
for (var n = 1; n <= 85; n++) {
final padded = n.toString().padLeft(2, '0');
final key = ZoneIdentity.matchKey('SAR $padded Zona Restringida');
expect(key, 'sar_$n');
expect(keys.add(key), isTrue, reason: 'duplicate key for SAR $padded');
}
});

test('distinct CTR and TMA designators produce distinct keys', () {
final designators = [
'EZEIZA CTR',
'AEROPARQUE JORGE NEWBERY CTR',
'CORDOBA CTR',
'MENDOZA CTR',
'TMA BAIRES',
'TMA CORDOBA',
'TMA MENDOZA',
];
final keys = designators.map(ZoneIdentity.matchKey).toList();
expect(keys.whereType<String>(), hasLength(designators.length));
expect(keys.toSet(), hasLength(designators.length));
});
});
}
141 changes: 141 additions & 0 deletions packages/zones_api_client/test/zone_merger_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import 'package:test/test.dart';
import 'package:zones_api_client/zones_api_client.dart';

void main() {
ZoneData openAip({
required String id,
required String name,
String categoryId = 'controlled_airspace',
List<List<double>>? polygon,
}) =>
ZoneData(
id: id,
name: name,
categoryId: categoryId,
latitude: -34.82,
longitude: -58.53,
radiusMeters: 15000,
polygon: polygon ??
const [
[-58.6, -34.9],
[-58.4, -34.9],
[-58.4, -34.7],
[-58.6, -34.7],
],
allowedPermissionIds: const {'controlled'},
details: 'OpenAIP geometry',
source: ZoneSourceIds.openaip,
);

ZoneData aip({
required String id,
required String name,
String categoryId = 'restricted',
Set<String> permissions = const {'special_permit'},
double? lowerAgl,
}) =>
ZoneData(
id: id,
name: name,
categoryId: categoryId,
latitude: -34.81,
longitude: -58.52,
radiusMeters: 14000,
allowedPermissionIds: permissions,
details: 'AIP authority',
lowerLimitMetersAgl: lowerAgl,
source: ZoneSourceIds.aip,
);

group('ZoneMerger.merge', () {
test('collapses an AIP+OpenAIP pair into one merged zone', () {
final result = ZoneMerger.merge([
openAip(id: 'openaip_eze', name: 'EZEIZA CTR'),
aip(id: 'anac_ctr_saez', name: 'CTR EZEIZA', lowerAgl: 0),
]);

final zone = result.single;
// OpenAIP geometry...
expect(zone.id, 'openaip_eze');
expect(zone.polygon, isNotNull);
expect(zone.source, ZoneSourceIds.openaip);
// ...with AIP authority.
expect(zone.confirmedBy, ZoneSourceIds.aip);
expect(zone.categoryId, 'restricted');
expect(zone.allowedPermissionIds, {'special_permit'});
expect(zone.lowerLimitMetersAgl, 0);
});

test('collapses each distinct pair independently (EZE + Aeroparque)', () {
final result = ZoneMerger.merge([
openAip(id: 'openaip_eze', name: 'EZEIZA CTR'),
openAip(id: 'openaip_aep', name: 'AEROPARQUE JORGE NEWBERY CTR'),
aip(id: 'anac_ctr_saez', name: 'CTR EZEIZA'),
aip(id: 'anac_ctr_sabe', name: 'AEROPARQUE CTR'),
]);

expect(result, hasLength(2));
expect(
result.every((z) => z.confirmedBy == ZoneSourceIds.aip),
isTrue,
);
expect(
result.map((z) => z.id),
containsAll(['openaip_eze', 'openaip_aep']),
);
});

test('prefers the OpenAIP zone that carries a real polygon', () {
final result = ZoneMerger.merge([
// Same matchKey, but only the second OpenAIP has geometry.
openAip(
id: 'openaip_eze_circle',
name: 'EZEIZA CTR',
polygon: const [],
),
openAip(id: 'openaip_eze_poly', name: 'EZEIZA CTR'),
aip(id: 'anac_ctr_saez', name: 'CTR EZEIZA'),
]);

// The circle OpenAIP zone is left untouched; the polygon one merges.
final mergedIds = result.map((z) => z.id).toList();
expect(mergedIds, contains('openaip_eze_poly'));
expect(
result.firstWhere((z) => z.id == 'openaip_eze_poly').confirmedBy,
ZoneSourceIds.aip,
);
});

test('keeps both zones when only one source is present (matchKey miss)',
() {
final input = [
openAip(id: 'openaip_sar01', name: 'SAR 01 Capital Federal'),
openAip(id: 'openaip_sar02', name: 'SAR 02 Campo de Mayo'),
];
expect(ZoneMerger.merge(input), input);
});

test('leaves keyless zones (no matchKey) untouched', () {
final input = [
aip(id: 'anac_x', name: 'Parque Nacional Iguazú'),
openAip(id: 'openaip_y', name: 'Some Local Field'),
];
expect(ZoneMerger.merge(input), input);
});

test('SAR designators pair across sources by number', () {
final result = ZoneMerger.merge([
openAip(
id: 'openaip_sar07',
name: 'SAR 07 Zarate',
categoryId: 'restricted',
),
aip(id: 'anac_sar_07', name: 'SAR 07 Central Atucha'),
]);

expect(result, hasLength(1));
expect(result.single.id, 'openaip_sar07');
expect(result.single.confirmedBy, ZoneSourceIds.aip);
});
});
}