diff --git a/src/main/java/dev/coph/flightscore/backend/Main.java b/src/main/java/dev/coph/flightscore/backend/Main.java index ef839f7..a0de1ef 100644 --- a/src/main/java/dev/coph/flightscore/backend/Main.java +++ b/src/main/java/dev/coph/flightscore/backend/Main.java @@ -6,22 +6,23 @@ 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.Track; +import dev.coph.flightscore.backend.track.parser.BalloonLiveParser; import dev.coph.simplelogger.GenericLogger; import dev.coph.simplelogger.LogLevel; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + public class Main { private static Backend backend; static void main(String[] args) { GenericLogger.instance().consoleLogLevel(LogLevel.DEBUG); GenericLogger.instance().fileLogLevel(LogLevel.INFO); - - Coordinate coordinate = new Coordinate(47.280509, 7.837391, Altitude.fromMeters(400)); - GridCoordinate convertToSwiss = CoordinateConverter.convert(coordinate, MapDatum.WGS84, MapDatum.CH1903, MapGrid.SWISS_GRID_LV03); - GridCoordinate convertToUTMETRS = CoordinateConverter.convert(coordinate, MapDatum.WGS84, MapDatum.ETRS89, MapGrid.UTM); - System.out.println(convertToSwiss); - System.out.println(convertToUTMETRS); - /* + Runtime.getRuntime().addShutdownHook(new Thread(() -> { if (backend != null) backend.onDisable(); })); @@ -32,8 +33,5 @@ public class Main { GenericLogger.info("Starting backend..."); backend.onEnable(); GenericLogger.success("Backend started!"); - */ - } - } diff --git a/src/main/java/dev/coph/flightscore/backend/track/Declaration.java b/src/main/java/dev/coph/flightscore/backend/track/Declaration.java index 4d2c618..c7fbea6 100644 --- a/src/main/java/dev/coph/flightscore/backend/track/Declaration.java +++ b/src/main/java/dev/coph/flightscore/backend/track/Declaration.java @@ -1,10 +1,16 @@ package dev.coph.flightscore.backend.track; import dev.coph.flightscore.backend.coordinate.Coordinate; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.Accessors; @Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor @Accessors(fluent = true) public class Declaration { private int number; diff --git a/src/main/java/dev/coph/flightscore/backend/track/MarkerDrop.java b/src/main/java/dev/coph/flightscore/backend/track/MarkerDrop.java index fad4335..f392d36 100644 --- a/src/main/java/dev/coph/flightscore/backend/track/MarkerDrop.java +++ b/src/main/java/dev/coph/flightscore/backend/track/MarkerDrop.java @@ -1,12 +1,18 @@ package dev.coph.flightscore.backend.track; import dev.coph.flightscore.backend.coordinate.Coordinate; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.Accessors; import java.time.Instant; @Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor @Accessors(fluent = true) public class MarkerDrop { private int number; diff --git a/src/main/java/dev/coph/flightscore/backend/track/Track.java b/src/main/java/dev/coph/flightscore/backend/track/Track.java index 6fc4f25..61c7289 100644 --- a/src/main/java/dev/coph/flightscore/backend/track/Track.java +++ b/src/main/java/dev/coph/flightscore/backend/track/Track.java @@ -2,13 +2,19 @@ package dev.coph.flightscore.backend.track; import dev.coph.flightscore.backend.pilot.Pilot; import dev.coph.flightscore.backend.track.header.TrackHeader; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.Accessors; import java.io.File; import java.util.List; @Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor @Accessors(fluent = true) public class Track { @@ -18,5 +24,5 @@ public class Track { private List declarations; private List markerDrops; private File trackFile; - + } diff --git a/src/main/java/dev/coph/flightscore/backend/track/parser/BalloonLiveParser.java b/src/main/java/dev/coph/flightscore/backend/track/parser/BalloonLiveParser.java index 164acd9..01e8365 100644 --- a/src/main/java/dev/coph/flightscore/backend/track/parser/BalloonLiveParser.java +++ b/src/main/java/dev/coph/flightscore/backend/track/parser/BalloonLiveParser.java @@ -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). + * + *

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.

+ * + *

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.

+ * + *

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).

+ */ 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 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 headers = new ArrayList<>(); + LocalDate currentDate; + List bFields = Collections.emptyList(); + List kFields = Collections.emptyList(); + final Map> eFields = new HashMap<>(); + final List points = new ArrayList<>(); + final List markers = new ArrayList<>(); + final Map declarationsByNumber = new LinkedHashMap<>(); + final Map 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 parseIFields(String body) { + List 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 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 fields = state.eFields.getOrDefault("XX0", Collections.emptyList()); + Map 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 fields = state.eFields.getOrDefault("XL1", Collections.emptyList()); + Map 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: + *
    + *
  • {@code EEEE/NNNN} — explicit slash split (any digit length per side)
  • + *
  • {@code EEEE/NNNN/AAA} — optional trailing altitude in meters
  • + *
  • {@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
  • + *
  • Free-form text (e.g. {@code "test"}): returns no coordinates.
  • + *
+ */ + 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 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 extractFields(String line, List fields) { + Map 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); } }