|
|
|
@@ -1,12 +1,575 @@
|
|
|
|
|
package dev.coph.flightscore.backend.track.parser;
|
|
|
|
|
|
|
|
|
|
import dev.coph.flightscore.backend.coordinate.Altitude;
|
|
|
|
|
import dev.coph.flightscore.backend.coordinate.Coordinate;
|
|
|
|
|
import dev.coph.flightscore.backend.coordinate.PositionValid;
|
|
|
|
|
import dev.coph.flightscore.backend.map.CoordinateConverter;
|
|
|
|
|
import dev.coph.flightscore.backend.map.GridCoordinate;
|
|
|
|
|
import dev.coph.flightscore.backend.map.MapDatum;
|
|
|
|
|
import dev.coph.flightscore.backend.map.MapGrid;
|
|
|
|
|
import dev.coph.flightscore.backend.track.Declaration;
|
|
|
|
|
import dev.coph.flightscore.backend.track.MarkerDrop;
|
|
|
|
|
import dev.coph.flightscore.backend.track.Track;
|
|
|
|
|
import dev.coph.flightscore.backend.track.TrackPoint;
|
|
|
|
|
import dev.coph.flightscore.backend.track.header.TrackHeader;
|
|
|
|
|
|
|
|
|
|
import java.time.Instant;
|
|
|
|
|
import java.time.LocalDate;
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
import java.time.LocalTime;
|
|
|
|
|
import java.time.ZoneOffset;
|
|
|
|
|
import java.time.format.DateTimeFormatter;
|
|
|
|
|
import java.util.ArrayList;
|
|
|
|
|
import java.util.Collections;
|
|
|
|
|
import java.util.HashMap;
|
|
|
|
|
import java.util.LinkedHashMap;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parser for the Balloon Live track file format (an IGC dialect produced by
|
|
|
|
|
* the Balloon Live App and the Balloon Live Sensor).
|
|
|
|
|
*
|
|
|
|
|
* <p>Supported records: A, H (all), I, J, B, K, E (XX0 marker drop,
|
|
|
|
|
* XX1/XL1 goal declarations) and G (signature, ignored). Unknown records and
|
|
|
|
|
* malformed lines are silently skipped to keep parsing robust.</p>
|
|
|
|
|
*
|
|
|
|
|
* <p>Goal declarations transmitted as digit strings (4/4, 5/4, 5/5, 5/6, 6/6,
|
|
|
|
|
* 6/7 — or any shorter variant) are decoded against the supplied
|
|
|
|
|
* reference coordinate. Every supported {@link MapGrid} is tried; the
|
|
|
|
|
* combination that yields the position closest to the reference is selected,
|
|
|
|
|
* so files from any competition area are handled without further hints.</p>
|
|
|
|
|
*
|
|
|
|
|
* <p>Digit substitution rule: the declaration digits are spliced into the
|
|
|
|
|
* trailing portion of the reference grid value. Normally the very last digit
|
|
|
|
|
* is forced to {@code 0} and the declaration digits sit in front of it. When
|
|
|
|
|
* the declaration is exactly as long as the modified part, the part is fully
|
|
|
|
|
* replaced (no trailing zero).</p>
|
|
|
|
|
*/
|
|
|
|
|
public class BalloonLiveParser implements TrackParser {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static final DateTimeFormatter DATE_DDMMYY = DateTimeFormatter.ofPattern("ddMMyy");
|
|
|
|
|
private static final double EARTH_RADIUS_METERS = 6_371_000.0;
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public Track parse(String[] lines, Coordinate referenceCoordinate) {
|
|
|
|
|
return null;
|
|
|
|
|
ParseState state = new ParseState();
|
|
|
|
|
if (lines == null) {
|
|
|
|
|
return buildTrack(state);
|
|
|
|
|
}
|
|
|
|
|
for (String raw : lines) {
|
|
|
|
|
if (raw == null) continue;
|
|
|
|
|
String line = stripLineEndings(raw);
|
|
|
|
|
if (line.isEmpty()) continue;
|
|
|
|
|
try {
|
|
|
|
|
switch (line.charAt(0)) {
|
|
|
|
|
case 'A' -> state.headers.add(line);
|
|
|
|
|
case 'H' -> handleHeader(state, line);
|
|
|
|
|
case 'I' -> state.bFields = parseIFields(line.substring(1));
|
|
|
|
|
case 'J' -> state.kFields = parseIFields(line.substring(1));
|
|
|
|
|
case 'B' -> handleBRecord(state, line);
|
|
|
|
|
case 'K' -> handleKRecord(state, line);
|
|
|
|
|
case 'E' -> handleERecord(state, line, referenceCoordinate);
|
|
|
|
|
case 'G' -> { /* signature - ignored by scoring */ }
|
|
|
|
|
default -> { /* unknown - skip */ }
|
|
|
|
|
}
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
// Skip malformed lines instead of aborting the whole file.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return buildTrack(state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Track buildTrack(ParseState state) {
|
|
|
|
|
List<Declaration> finalDeclarations = new ArrayList<>(state.declarationsByNumber.values());
|
|
|
|
|
return new Track()
|
|
|
|
|
.header(new TrackHeader(state.headers))
|
|
|
|
|
.trackPoints(state.points)
|
|
|
|
|
.declarations(finalDeclarations)
|
|
|
|
|
.markerDrops(state.markers);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- state
|
|
|
|
|
|
|
|
|
|
private static final class ParseState {
|
|
|
|
|
final List<String> headers = new ArrayList<>();
|
|
|
|
|
LocalDate currentDate;
|
|
|
|
|
List<IField> bFields = Collections.emptyList();
|
|
|
|
|
List<IField> kFields = Collections.emptyList();
|
|
|
|
|
final Map<String, List<IField>> eFields = new HashMap<>();
|
|
|
|
|
final List<TrackPoint> points = new ArrayList<>();
|
|
|
|
|
final List<MarkerDrop> markers = new ArrayList<>();
|
|
|
|
|
final Map<Integer, Declaration> declarationsByNumber = new LinkedHashMap<>();
|
|
|
|
|
final Map<Integer, String> declarationTextByNumber = new HashMap<>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** IGC I-record field descriptor, stored as 0-based [start, end) Java indices. */
|
|
|
|
|
private record IField(int startInclusive, int endExclusive, String code) {
|
|
|
|
|
String extract(String line) {
|
|
|
|
|
if (line == null || line.length() < endExclusive) return "";
|
|
|
|
|
return line.substring(startInclusive, endExclusive);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- headers
|
|
|
|
|
|
|
|
|
|
private void handleHeader(ParseState state, String line) {
|
|
|
|
|
state.headers.add(line);
|
|
|
|
|
if (line.length() < 5) return;
|
|
|
|
|
String code = line.substring(2, 5);
|
|
|
|
|
if ("DTE".equals(code)) {
|
|
|
|
|
String tail = line.substring(5).replace(":", "");
|
|
|
|
|
if (tail.length() >= 6) {
|
|
|
|
|
try {
|
|
|
|
|
state.currentDate = LocalDate.parse(tail.substring(0, 6), DATE_DDMMYY);
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if ("XII".equals(code)) {
|
|
|
|
|
String[] parts = line.split(":");
|
|
|
|
|
if (parts.length >= 3) {
|
|
|
|
|
state.eFields.put(parts[1], parseIFields(parts[2]));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- I/J record
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parses an IGC I/J-record body (the part after the leading I or J).
|
|
|
|
|
* Layout: 2-digit field count followed by {@code count * 7} chars per
|
|
|
|
|
* field: 2-digit start (1-based, inclusive), 2-digit end (1-based,
|
|
|
|
|
* inclusive), 3-char code.
|
|
|
|
|
*/
|
|
|
|
|
private static List<IField> parseIFields(String body) {
|
|
|
|
|
List<IField> fields = new ArrayList<>();
|
|
|
|
|
if (body == null || body.length() < 2) return fields;
|
|
|
|
|
int count;
|
|
|
|
|
try {
|
|
|
|
|
count = Integer.parseInt(body.substring(0, 2));
|
|
|
|
|
} catch (NumberFormatException e) {
|
|
|
|
|
return fields;
|
|
|
|
|
}
|
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
|
|
|
int off = 2 + i * 7;
|
|
|
|
|
if (body.length() < off + 7) break;
|
|
|
|
|
try {
|
|
|
|
|
int start = Integer.parseInt(body.substring(off, off + 2));
|
|
|
|
|
int end = Integer.parseInt(body.substring(off + 2, off + 4));
|
|
|
|
|
String code = body.substring(off + 4, off + 7);
|
|
|
|
|
fields.add(new IField(start - 1, end, code));
|
|
|
|
|
} catch (NumberFormatException ignored) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fields;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- B-record
|
|
|
|
|
|
|
|
|
|
private void handleBRecord(ParseState state, String line) {
|
|
|
|
|
if (line.length() < 35) return;
|
|
|
|
|
String time = line.substring(1, 7);
|
|
|
|
|
String latStr = line.substring(7, 15);
|
|
|
|
|
String lonStr = line.substring(15, 24);
|
|
|
|
|
char fix = line.charAt(24);
|
|
|
|
|
String pressureStr = line.substring(25, 30);
|
|
|
|
|
String gpsAltStr = line.substring(30, 35);
|
|
|
|
|
|
|
|
|
|
Map<String, String> ext = extractFields(line, state.bFields);
|
|
|
|
|
String lad = ext.getOrDefault("LAD", "");
|
|
|
|
|
String lod = ext.getOrDefault("LOD", "");
|
|
|
|
|
String pad = ext.getOrDefault("PAD", "");
|
|
|
|
|
|
|
|
|
|
double latitude = parseLatitude(latStr, lad);
|
|
|
|
|
double longitude = parseLongitude(lonStr, lod);
|
|
|
|
|
Altitude pressureAlt = parseAltitudeWithDecimal(pressureStr, pad);
|
|
|
|
|
Altitude gpsAlt = parseAltitude(gpsAltStr);
|
|
|
|
|
PositionValid valid = fix == 'A' ? PositionValid.FIX_3D : PositionValid.NO_FIX_OR_2D;
|
|
|
|
|
int gpsAccuracy = parseIntSafe(ext.getOrDefault("FXA", ""));
|
|
|
|
|
int satellites = parseIntSafe(ext.getOrDefault("SIU", ""));
|
|
|
|
|
double variometer = parseVariometer(ext.getOrDefault("VAR", ""));
|
|
|
|
|
Instant when = buildInstant(state.currentDate, time);
|
|
|
|
|
|
|
|
|
|
state.points.add(new TrackPoint(when, latitude, longitude, valid,
|
|
|
|
|
pressureAlt, gpsAlt, gpsAccuracy, satellites, lad + lod + pad, variometer));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- K-record
|
|
|
|
|
|
|
|
|
|
private void handleKRecord(ParseState state, String line) {
|
|
|
|
|
// Date is the only field currently transmitted (J010813DTE).
|
|
|
|
|
for (IField f : state.kFields) {
|
|
|
|
|
if ("DTE".equals(f.code) && line.length() >= f.endExclusive) {
|
|
|
|
|
try {
|
|
|
|
|
state.currentDate = LocalDate.parse(f.extract(line), DATE_DDMMYY);
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Fallback when J-record was missing: hard-coded slice 8..13.
|
|
|
|
|
if (state.kFields.isEmpty() && line.length() >= 13) {
|
|
|
|
|
try {
|
|
|
|
|
state.currentDate = LocalDate.parse(line.substring(7, 13), DATE_DDMMYY);
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- E-record
|
|
|
|
|
|
|
|
|
|
private void handleERecord(ParseState state, String line, Coordinate reference) {
|
|
|
|
|
if (line.length() < 10) return;
|
|
|
|
|
String time = line.substring(1, 7);
|
|
|
|
|
String type = line.substring(7, 10);
|
|
|
|
|
Instant when = buildInstant(state.currentDate, time);
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "XX0" -> parseMarkerDrop(state, line, when);
|
|
|
|
|
case "XX1" -> parseGoalText(state, line, reference);
|
|
|
|
|
case "XL1" -> parseGoalPosition(state, line, reference);
|
|
|
|
|
default -> { /* XXC signature, XS0/XS1 source change, XDn debug, etc. */ }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void parseMarkerDrop(ParseState state, String line, Instant when) {
|
|
|
|
|
if (line.length() < 40) return;
|
|
|
|
|
int number = parseIntSafe(line.substring(10, 12));
|
|
|
|
|
String latStr = line.substring(12, 20);
|
|
|
|
|
String lonStr = line.substring(20, 29);
|
|
|
|
|
char fix = line.charAt(29);
|
|
|
|
|
String pressureStr = line.substring(30, 35);
|
|
|
|
|
String gpsAltStr = line.substring(35, 40);
|
|
|
|
|
|
|
|
|
|
List<IField> fields = state.eFields.getOrDefault("XX0", Collections.emptyList());
|
|
|
|
|
Map<String, String> ext = extractFields(line, fields);
|
|
|
|
|
String lad = ext.getOrDefault("LAD", "");
|
|
|
|
|
String lod = ext.getOrDefault("LOD", "");
|
|
|
|
|
String pad = ext.getOrDefault("PAD", "");
|
|
|
|
|
|
|
|
|
|
double latitude = parseLatitude(latStr, lad);
|
|
|
|
|
double longitude = parseLongitude(lonStr, lod);
|
|
|
|
|
Altitude pressureAlt = parseAltitudeWithDecimal(pressureStr, pad);
|
|
|
|
|
Altitude gpsAlt = parseAltitude(gpsAltStr);
|
|
|
|
|
// fix validity captured but not stored on MarkerDrop today.
|
|
|
|
|
if (fix == 'A' || fix == 'V') {
|
|
|
|
|
// no-op; reserved for future use.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Coordinate location = new Coordinate(latitude, longitude, pressureAlt, gpsAlt);
|
|
|
|
|
state.markers.add(new MarkerDrop(number, location, when));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void parseGoalText(ParseState state, String line, Coordinate reference) {
|
|
|
|
|
if (line.length() < 12) return;
|
|
|
|
|
int number = parseIntSafe(line.substring(10, 12));
|
|
|
|
|
String declString = line.substring(12);
|
|
|
|
|
state.declarationTextByNumber.putIfAbsent(number, declString);
|
|
|
|
|
|
|
|
|
|
Declaration existing = state.declarationsByNumber.computeIfAbsent(number, n -> new Declaration().number(n));
|
|
|
|
|
Coordinate decodingReference = pickDecodingReference(existing.positionAtDeclaration(), reference);
|
|
|
|
|
applyDecodedDeclaration(existing, declString, decodingReference);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void parseGoalPosition(ParseState state, String line, Coordinate reference) {
|
|
|
|
|
if (line.length() < 40) return;
|
|
|
|
|
int number = parseIntSafe(line.substring(10, 12));
|
|
|
|
|
String latStr = line.substring(12, 20);
|
|
|
|
|
String lonStr = line.substring(20, 29);
|
|
|
|
|
// char fix = line.charAt(29); // captured but not currently stored
|
|
|
|
|
String pressureStr = line.substring(30, 35);
|
|
|
|
|
String gpsAltStr = line.substring(35, 40);
|
|
|
|
|
|
|
|
|
|
List<IField> fields = state.eFields.getOrDefault("XL1", Collections.emptyList());
|
|
|
|
|
Map<String, String> ext = extractFields(line, fields);
|
|
|
|
|
String lad = ext.getOrDefault("LAD", "");
|
|
|
|
|
String lod = ext.getOrDefault("LOD", "");
|
|
|
|
|
String pad = ext.getOrDefault("PAD", "");
|
|
|
|
|
|
|
|
|
|
double latitude = parseLatitude(latStr, lad);
|
|
|
|
|
double longitude = parseLongitude(lonStr, lod);
|
|
|
|
|
Altitude pressureAlt = parseAltitudeWithDecimal(pressureStr, pad);
|
|
|
|
|
Altitude gpsAlt = parseAltitude(gpsAltStr);
|
|
|
|
|
|
|
|
|
|
int declStart = 40;
|
|
|
|
|
for (IField f : fields) {
|
|
|
|
|
if (f.endExclusive > declStart) declStart = f.endExclusive;
|
|
|
|
|
}
|
|
|
|
|
String declString = line.length() > declStart ? line.substring(declStart) : "";
|
|
|
|
|
|
|
|
|
|
Coordinate positionAtDeclaration = new Coordinate(latitude, longitude, pressureAlt, gpsAlt);
|
|
|
|
|
Declaration declaration = state.declarationsByNumber.computeIfAbsent(number, n -> new Declaration().number(n));
|
|
|
|
|
declaration.positionAtDeclaration(positionAtDeclaration);
|
|
|
|
|
// Prefer the in-file declaration position as decoding reference (closest to the target).
|
|
|
|
|
Coordinate decodingReference = pickDecodingReference(positionAtDeclaration, reference);
|
|
|
|
|
applyDecodedDeclaration(declaration, declString, decodingReference);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Coordinate pickDecodingReference(Coordinate primary, Coordinate fallback) {
|
|
|
|
|
return primary != null ? primary : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void applyDecodedDeclaration(Declaration target, String declString, Coordinate reference) {
|
|
|
|
|
DecodedDeclaration decoded = decodeDeclarationString(declString, reference);
|
|
|
|
|
if (!decoded.eastingDigits.isEmpty()) {
|
|
|
|
|
target.originalDeclarationEasting(decoded.eastingDigits);
|
|
|
|
|
}
|
|
|
|
|
if (!decoded.northingDigits.isEmpty()) {
|
|
|
|
|
target.originalDeclarationNorthing(decoded.northingDigits);
|
|
|
|
|
}
|
|
|
|
|
if (decoded.altitudeDeclared) {
|
|
|
|
|
target.isHeightPilotDeclared(true);
|
|
|
|
|
}
|
|
|
|
|
if (decoded.position != null && target.declaration() == null) {
|
|
|
|
|
target.declaration(decoded.position);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- coords
|
|
|
|
|
|
|
|
|
|
private static double parseLatitude(String latStr, String additionalDigit) {
|
|
|
|
|
if (latStr.length() < 8) return 0.0;
|
|
|
|
|
int dd = parseIntSafe(latStr.substring(0, 2));
|
|
|
|
|
int mm = parseIntSafe(latStr.substring(2, 4));
|
|
|
|
|
String mmm = latStr.substring(4, 7);
|
|
|
|
|
if (additionalDigit != null && !additionalDigit.isEmpty()) mmm += additionalDigit;
|
|
|
|
|
char hemisphere = latStr.charAt(7);
|
|
|
|
|
double minutes = mm + Double.parseDouble("0." + mmm);
|
|
|
|
|
double degrees = dd + minutes / 60.0;
|
|
|
|
|
return hemisphere == 'S' ? -degrees : degrees;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static double parseLongitude(String lonStr, String additionalDigit) {
|
|
|
|
|
if (lonStr.length() < 9) return 0.0;
|
|
|
|
|
int ddd = parseIntSafe(lonStr.substring(0, 3));
|
|
|
|
|
int mm = parseIntSafe(lonStr.substring(3, 5));
|
|
|
|
|
String mmm = lonStr.substring(5, 8);
|
|
|
|
|
if (additionalDigit != null && !additionalDigit.isEmpty()) mmm += additionalDigit;
|
|
|
|
|
char hemisphere = lonStr.charAt(8);
|
|
|
|
|
double minutes = mm + Double.parseDouble("0." + mmm);
|
|
|
|
|
double degrees = ddd + minutes / 60.0;
|
|
|
|
|
return hemisphere == 'W' ? -degrees : degrees;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Altitude parseAltitude(String value) {
|
|
|
|
|
if (value == null || value.isEmpty()) return null;
|
|
|
|
|
try {
|
|
|
|
|
return Altitude.fromMeters(Integer.parseInt(value.trim()));
|
|
|
|
|
} catch (NumberFormatException e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Altitude parseAltitudeWithDecimal(String meters, String decimeters) {
|
|
|
|
|
if (meters == null || meters.isEmpty()) return null;
|
|
|
|
|
try {
|
|
|
|
|
double m = Integer.parseInt(meters.trim());
|
|
|
|
|
if (decimeters != null && !decimeters.isEmpty()) {
|
|
|
|
|
m += Integer.parseInt(decimeters) / 10.0;
|
|
|
|
|
}
|
|
|
|
|
return Altitude.fromMeters(m);
|
|
|
|
|
} catch (NumberFormatException e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static double parseVariometer(String value) {
|
|
|
|
|
if (value == null || value.isEmpty()) return 0.0;
|
|
|
|
|
try {
|
|
|
|
|
// VAR is dm/s; positive = climb, negative = sink.
|
|
|
|
|
return Integer.parseInt(value.trim()) / 10.0;
|
|
|
|
|
} catch (NumberFormatException e) {
|
|
|
|
|
return 0.0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- declarations
|
|
|
|
|
|
|
|
|
|
private record DecodedDeclaration(String eastingDigits,
|
|
|
|
|
String northingDigits,
|
|
|
|
|
Coordinate position,
|
|
|
|
|
boolean altitudeDeclared) {
|
|
|
|
|
static final DecodedDeclaration EMPTY = new DecodedDeclaration("", "", null, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Decodes a goal declaration string. Supported shapes:
|
|
|
|
|
* <ul>
|
|
|
|
|
* <li>{@code EEEE/NNNN} — explicit slash split (any digit length per side)</li>
|
|
|
|
|
* <li>{@code EEEE/NNNN/AAA} — optional trailing altitude in meters</li>
|
|
|
|
|
* <li>{@code EEEENNNN} — no separator: the parser probes the splits
|
|
|
|
|
* {@code 4/4 5/4 5/5 5/6 6/6 6/7} and picks the best grid match</li>
|
|
|
|
|
* <li>Free-form text (e.g. {@code "test"}): returns no coordinates.</li>
|
|
|
|
|
* </ul>
|
|
|
|
|
*/
|
|
|
|
|
private DecodedDeclaration decodeDeclarationString(String raw, Coordinate reference) {
|
|
|
|
|
if (raw == null) return DecodedDeclaration.EMPTY;
|
|
|
|
|
String trimmed = raw.trim();
|
|
|
|
|
if (trimmed.isEmpty()) return DecodedDeclaration.EMPTY;
|
|
|
|
|
|
|
|
|
|
String coords = trimmed;
|
|
|
|
|
Altitude declaredAltitude = null;
|
|
|
|
|
boolean altitudeDeclared = false;
|
|
|
|
|
if (coords.contains("/")) {
|
|
|
|
|
String[] parts = coords.split("/");
|
|
|
|
|
if (parts.length >= 3 && parts[parts.length - 1].matches("-?\\d+")) {
|
|
|
|
|
declaredAltitude = Altitude.fromMeters(Integer.parseInt(parts[parts.length - 1]));
|
|
|
|
|
altitudeDeclared = true;
|
|
|
|
|
StringBuilder sb = new StringBuilder();
|
|
|
|
|
for (int i = 0; i < parts.length - 1; i++) {
|
|
|
|
|
if (i > 0) sb.append('/');
|
|
|
|
|
sb.append(parts[i]);
|
|
|
|
|
}
|
|
|
|
|
coords = sb.toString();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<int[]> splits = new ArrayList<>();
|
|
|
|
|
if (coords.contains("/")) {
|
|
|
|
|
String[] split = coords.split("/", 2);
|
|
|
|
|
if (split.length == 2) splits.add(new int[]{split[0].length(), split[1].length()});
|
|
|
|
|
coords = coords.replace("/", "");
|
|
|
|
|
} else {
|
|
|
|
|
int len = coords.length();
|
|
|
|
|
int[][] preferred = {{4, 4}, {5, 4}, {4, 5}, {5, 5}, {5, 6}, {6, 5}, {6, 6}, {6, 7}, {7, 6}, {7, 7}};
|
|
|
|
|
for (int[] s : preferred) {
|
|
|
|
|
if (s[0] + s[1] == len) splits.add(s);
|
|
|
|
|
}
|
|
|
|
|
if (splits.isEmpty()) {
|
|
|
|
|
splits.add(new int[]{len / 2, len - len / 2});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!coords.chars().allMatch(Character::isDigit) || coords.isEmpty()) {
|
|
|
|
|
return DecodedDeclaration.EMPTY;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String bestE = "";
|
|
|
|
|
String bestN = "";
|
|
|
|
|
Coordinate bestPosition = null;
|
|
|
|
|
double bestDistance = Double.MAX_VALUE;
|
|
|
|
|
|
|
|
|
|
for (int[] split : splits) {
|
|
|
|
|
int ne = split[0];
|
|
|
|
|
int nn = split[1];
|
|
|
|
|
if (ne <= 0 || nn <= 0 || ne + nn > coords.length()) continue;
|
|
|
|
|
String eDigits = coords.substring(0, ne);
|
|
|
|
|
String nDigits = coords.substring(ne, ne + nn);
|
|
|
|
|
|
|
|
|
|
if (reference == null) {
|
|
|
|
|
if (bestE.isEmpty()) { bestE = eDigits; bestN = nDigits; }
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (MapGrid grid : MapGrid.values()) {
|
|
|
|
|
if (grid == MapGrid.LAT_LONG) continue;
|
|
|
|
|
for (boolean eTrail : TRAILING_OPTIONS) {
|
|
|
|
|
for (boolean nTrail : TRAILING_OPTIONS) {
|
|
|
|
|
Coordinate candidate = applyOverride(reference, grid, eDigits, nDigits,
|
|
|
|
|
eTrail, nTrail, declaredAltitude);
|
|
|
|
|
if (candidate == null) continue;
|
|
|
|
|
double distance = greatCircleDistance(reference, candidate);
|
|
|
|
|
if (distance < bestDistance) {
|
|
|
|
|
bestDistance = distance;
|
|
|
|
|
bestPosition = candidate;
|
|
|
|
|
bestE = eDigits;
|
|
|
|
|
bestN = nDigits;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return new DecodedDeclaration(bestE, bestN, bestPosition, altitudeDeclared);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static final boolean[] TRAILING_OPTIONS = {false, true};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Splices the declaration digits into the trailing portion of the
|
|
|
|
|
* reference grid value and returns the resulting geographic coordinate.
|
|
|
|
|
* If {@code trailingZero} is {@code true} the declaration sits in front of
|
|
|
|
|
* a single {@code 0} digit (the "normal" balloon rule); otherwise
|
|
|
|
|
* the declaration replaces the entire matching part.
|
|
|
|
|
*/
|
|
|
|
|
private Coordinate applyOverride(Coordinate reference, MapGrid grid,
|
|
|
|
|
String eDigits, String nDigits,
|
|
|
|
|
boolean eTrail, boolean nTrail,
|
|
|
|
|
Altitude declaredAltitude) {
|
|
|
|
|
try {
|
|
|
|
|
GridCoordinate refGrid = CoordinateConverter.toGrid(reference, MapDatum.WGS84, grid);
|
|
|
|
|
double eRef = refGrid.easting();
|
|
|
|
|
double nRef = refGrid.northing();
|
|
|
|
|
double eVal = applyDigitOverride(eRef, eDigits, eTrail);
|
|
|
|
|
double nVal = applyDigitOverride(nRef, nDigits, nTrail);
|
|
|
|
|
Altitude baro = declaredAltitude != null ? declaredAltitude : reference.barometricAltitude();
|
|
|
|
|
Altitude gps = declaredAltitude != null ? declaredAltitude : reference.gpsAltitude();
|
|
|
|
|
GridCoordinate decl = new GridCoordinate(eVal, nVal, baro, gps,
|
|
|
|
|
refGrid.utmZone(), refGrid.southernHemisphere());
|
|
|
|
|
return CoordinateConverter.toGeographic(decl, MapDatum.WGS84, grid);
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static double applyDigitOverride(double referenceValue, String declarationDigits, boolean trailingZero) {
|
|
|
|
|
long whole = (long) Math.floor(Math.abs(referenceValue));
|
|
|
|
|
String refStr = Long.toString(whole);
|
|
|
|
|
String suffix = trailingZero ? declarationDigits + "0" : declarationDigits;
|
|
|
|
|
if (refStr.length() < suffix.length()) {
|
|
|
|
|
StringBuilder pad = new StringBuilder();
|
|
|
|
|
for (int i = 0; i < suffix.length() - refStr.length(); i++) pad.append('0');
|
|
|
|
|
refStr = pad.append(refStr).toString();
|
|
|
|
|
}
|
|
|
|
|
String prefix = refStr.substring(0, refStr.length() - suffix.length());
|
|
|
|
|
double result = Double.parseDouble(prefix + suffix);
|
|
|
|
|
return referenceValue < 0 ? -result : result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static double greatCircleDistance(Coordinate a, Coordinate b) {
|
|
|
|
|
double midLat = Math.toRadians((a.latitude() + b.latitude()) / 2.0);
|
|
|
|
|
double dLat = Math.toRadians(a.latitude() - b.latitude());
|
|
|
|
|
double dLon = Math.toRadians(a.longitude() - b.longitude()) * Math.cos(midLat);
|
|
|
|
|
return Math.hypot(dLat, dLon) * EARTH_RADIUS_METERS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- utility
|
|
|
|
|
|
|
|
|
|
private static Map<String, String> extractFields(String line, List<IField> fields) {
|
|
|
|
|
Map<String, String> out = new HashMap<>();
|
|
|
|
|
for (IField f : fields) {
|
|
|
|
|
if (line.length() >= f.endExclusive) {
|
|
|
|
|
out.put(f.code, line.substring(f.startInclusive, f.endExclusive));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Instant buildInstant(LocalDate date, String hhmmss) {
|
|
|
|
|
if (hhmmss == null || hhmmss.length() < 6) return null;
|
|
|
|
|
try {
|
|
|
|
|
int hh = Integer.parseInt(hhmmss.substring(0, 2));
|
|
|
|
|
int mm = Integer.parseInt(hhmmss.substring(2, 4));
|
|
|
|
|
int ss = Integer.parseInt(hhmmss.substring(4, 6));
|
|
|
|
|
LocalDate effective = date != null ? date : LocalDate.ofEpochDay(0);
|
|
|
|
|
return LocalDateTime.of(effective, LocalTime.of(hh, mm, ss)).toInstant(ZoneOffset.UTC);
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int parseIntSafe(String value) {
|
|
|
|
|
if (value == null) return 0;
|
|
|
|
|
String trimmed = value.trim();
|
|
|
|
|
if (trimmed.isEmpty()) return 0;
|
|
|
|
|
try { return Integer.parseInt(trimmed); }
|
|
|
|
|
catch (NumberFormatException e) { return 0; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static String stripLineEndings(String raw) {
|
|
|
|
|
int end = raw.length();
|
|
|
|
|
while (end > 0) {
|
|
|
|
|
char c = raw.charAt(end - 1);
|
|
|
|
|
if (c == '\r' || c == '\n') end--;
|
|
|
|
|
else break;
|
|
|
|
|
}
|
|
|
|
|
return end == raw.length() ? raw : raw.substring(0, end);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|