Add BalloonLiveParser implementation for parsing Balloon Live IGC files and integrate with track model

- Fully implement `BalloonLiveParser` to parse Balloon Live (IGC dialect) files, handling headers, B, K, and E records.
- Update `Track`, `Declaration`, and `MarkerDrop` models with Lombok for simplified field management.
- Refactor `Main` class to include necessary imports and clean unused code.
This commit is contained in:
Jan Meinl
2026-05-15 13:13:07 +02:00
parent 91c276349d
commit a722fae4c9
5 changed files with 592 additions and 13 deletions
@@ -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!");
*/
}
}
@@ -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;
@@ -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;
@@ -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<Declaration> declarations;
private List<MarkerDrop> markerDrops;
private File trackFile;
}
@@ -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 &mdash; 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} &mdash; explicit slash split (any digit length per side)</li>
* <li>{@code EEEE/NNNN/AAA} &mdash; optional trailing altitude in meters</li>
* <li>{@code EEEENNNN} &mdash; 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 &quot;normal&quot; 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);
}
}