Initial commit: LAS Stream Viewer (Quarkus backend + React log-plot UI)

This commit is contained in:
2026-06-02 15:49:29 +05:30
commit acdbb8b340
47 changed files with 6870 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
package com.oiusa.las.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.oiusa.las.model.Curve;
import com.oiusa.las.model.ResolvedRole;
/**
* Maps the raw LAS channel mnemonics to standard drilling "roles" (ROP, WOB, total gas, stick-slip,
* …) so the UI can build a proper multi-track log plot instead of a wall of numbers.
*
* <p>Resolution is by exact mnemonic first (Pason names are stable), then a description-keyword
* fallback for robustness across exports. Whatever auto-resolves is just the default — the UI lets
* the engineer reassign any track to any of the file's curves.
*/
public final class ChannelRoles {
public record RoleDef(String key, String label, String group, String unit,
double defMin, double defMax, List<String> aliases) {}
/** group keys, in display order */
public static final List<String> GROUPS = List.of("index", "mechanics", "hydraulics", "gas", "directional");
// The role table. unit is expected unit (resolved curve's own unit wins for display); defMin/defMax
// are sensible physical scales so a single garbage spike can't flatten the real trace.
public static final List<RoleDef> ROLES = List.of(
// ---- index / state ----
new RoleDef("holeDepth", "Hole Depth", "index", "ft", 0, 25000, List.of("DEPT")),
new RoleDef("bitDepth", "Bit Depth", "index", "ft", 0, 25000, List.of("BDEP")),
new RoleDef("time", "Time", "index", "s", 0, 0, List.of("TIME")),
new RoleDef("tvd", "TVD", "index", "ft", 0, 15000, List.of("TVDHD", "TVDBD")),
new RoleDef("onBottom", "On Bottom", "index", "", 0, 1, List.of("ONBTM")),
// ---- drilling mechanics ----
new RoleDef("rop", "ROP", "mechanics", "ft/hr", 0, 300, List.of("ROP", "IROP", "OBR", "OROP")),
new RoleDef("wob", "WOB", "mechanics", "klbs", 0, 80, List.of("WOB", "ADWOB")),
new RoleDef("rpm", "Rotary RPM", "mechanics", "RPM", 0, 250, List.of("RPM", "TDROT")),
new RoleDef("bitRpm", "Bit RPM", "mechanics", "RPM", 0, 300, List.of("BR", "MTRPM")),
new RoleDef("torque", "Torque", "mechanics", "kft-lbf", 0, 50, List.of("TDTOR", "TOR", "BITOR")),
new RoleDef("mse", "MSE", "mechanics", "kpsi", 0, 60, List.of("MSED")),
new RoleDef("hookload", "Hook Load", "mechanics", "klbs", 0, 500, List.of("HL", "CSW", "STRWT")),
new RoleDef("blockHeight", "Block Height", "mechanics", "ft", 0, 140, List.of("BHT", "ADBLP")),
new RoleDef("diffPress", "Diff Press", "mechanics", "psi", 0, 2000, List.of("DIFP")),
new RoleDef("doc", "Depth of Cut", "mechanics", "in", 0, 1, List.of("DOC")),
new RoleDef("overpull", "Over Pull", "mechanics", "klbs", 0, 100, List.of("OVRP")),
// ---- hydraulics / well control ----
new RoleDef("spp", "Standpipe Press", "hydraulics", "psi", 0, 5000, List.of("SPP", "UFSPP")),
new RoleDef("flow", "Flow", "hydraulics", "%", 0, 100, List.of("FLOW", "FEST")),
new RoleDef("spm1", "Pump 1 SPM", "hydraulics", "SPM", 0, 150, List.of("SPM1")),
new RoleDef("spm2", "Pump 2 SPM", "hydraulics", "SPM", 0, 150, List.of("SPM2")),
new RoleDef("spmTotal", "Total SPM", "hydraulics", "SPM", 0, 400, List.of("SKTtl")),
new RoleDef("pumpOutput", "Pump Output", "hydraulics", "gpm", 0, 1200, List.of("TPO")),
new RoleDef("casingPress", "Casing Press", "hydraulics", "psi", 0, 3000, List.of("PCAS")),
new RoleDef("mudVolume", "Total Mud Vol", "hydraulics", "bbl", 0, 1500, List.of("MV", "SIMUD")),
new RoleDef("gainLoss", "Pit Gain/Loss", "hydraulics", "bbl", -50, 50, List.of("VTGL", "GLA1")),
new RoleDef("tripTank", "Trip Tank", "hydraulics", "bbl", 0, 200, List.of("MVTT", "MVTT1", "TTACC")),
// ---- mud gas / formation ----
new RoleDef("totalGas", "Total Gas", "gas", "%", 0, 100, List.of("PGAS", "3GAS", "WGASP")),
new RoleDef("c1", "C1 Methane", "gas", "ppm", 0, 50000, List.of("C1M")),
new RoleDef("c2", "C2 Ethane", "gas", "ppm", 0, 10000, List.of("C2M")),
new RoleDef("c3", "C3 Propane", "gas", "ppm", 0, 5000, List.of("C3M")),
new RoleDef("ic4", "iC4", "gas", "ppm", 0, 2000, List.of("IC4")),
new RoleDef("nc4", "nC4", "gas", "ppm", 0, 2000, List.of("NC4")),
new RoleDef("ic5", "iC5", "gas", "ppm", 0, 1000, List.of("IC5")),
new RoleDef("nc5", "nC5", "gas", "ppm", 0, 1000, List.of("NC5")),
new RoleDef("gamma", "Gamma", "gas", "gAPI", 0, 150, List.of("GAM", "GAMB")),
new RoleDef("h2s", "H2S", "gas", "ppm", 0, 100, List.of("H2S")),
// ---- directional & drilling dynamics ----
new RoleDef("incl", "Inclination", "directional", "deg", 0, 110, List.of("INCL", "DYNIN")),
new RoleDef("azi", "Azimuth", "directional", "deg", 0, 360, List.of("AZ", "DYNAZ")),
new RoleDef("toolface", "Tool Face", "directional", "deg", 0, 360, List.of("TF", "GTF", "MTF", "ATFAV")),
new RoleDef("stickSlip", "Stick-Slip", "directional", "%", 0, 100, List.of("SSSI", "DTSEA")),
new RoleDef("vibeAxial", "Axial Vibe", "directional", "g", 0, 10, List.of("DAVAM")),
new RoleDef("vibeLateral", "Lateral Vibe", "directional", "g", 0, 10, List.of("DAVLM")),
new RoleDef("vibeHfto", "HFTO Vibe", "directional", "g", 0, 10, List.of("DAVHM")),
new RoleDef("slideRotate", "Slide/Rotate", "directional", "", 0, 1, List.of("ASR"))
);
/** Physical default [min,max] display scale for a role key (0,0 if unknown / index role). */
public static double[] defaultScale(String key) {
for (RoleDef r : ROLES) if (r.key().equals(key)) return new double[]{ r.defMin(), r.defMax() };
return new double[]{ 0, 0 };
}
private ChannelRoles() {}
/** Resolve every role against the file's curves; missing roles are simply omitted. */
public static Map<String, ResolvedRole> resolve(List<Curve> curves) {
// index curves by upper mnemonic
Map<String, Curve> byMnem = new LinkedHashMap<>();
for (Curve c : curves) byMnem.put(c.mnemonic().toUpperCase(Locale.ROOT), c);
Map<String, ResolvedRole> out = new LinkedHashMap<>();
for (RoleDef r : ROLES) {
Curve hit = null;
for (String alias : r.aliases()) {
Curve c = byMnem.get(alias.toUpperCase(Locale.ROOT));
if (c != null) { hit = c; break; }
}
if (hit == null) hit = byDescription(curves, r);
if (hit != null) {
out.put(r.key(), new ResolvedRole(r.key(), r.label(), r.group(),
hit.mnemonic(), hit.unit(), hit.description(), hit.column()));
}
}
return out;
}
private static Curve byDescription(List<Curve> curves, RoleDef r) {
// very light keyword fallback derived from the role label
String kw = r.label().toLowerCase(Locale.ROOT);
for (Curve c : curves) {
String d = c.description() == null ? "" : c.description().toLowerCase(Locale.ROOT);
if (!d.isEmpty() && d.contains(kw)) return c;
}
return null;
}
/** Distinct data columns that a set of resolved roles needs (sorted ascending). */
public static int[] neededColumns(Map<String, ResolvedRole> roles, int... extra) {
java.util.TreeSet<Integer> set = new java.util.TreeSet<>();
for (ResolvedRole r : roles.values()) if (r.column() >= 0) set.add(r.column());
for (int e : extra) if (e >= 0) set.add(e);
int[] cols = new int[set.size()];
int i = 0;
for (int v : set) cols[i++] = v;
return cols;
}
/** Default track layout (ordered) used by the UI as the starting point. */
public static List<List<String>> defaultTracks() {
List<List<String>> t = new ArrayList<>();
t.add(List.of("gamma"));
t.add(List.of("rop"));
t.add(List.of("wob", "rpm"));
t.add(List.of("torque", "mse"));
t.add(List.of("spp", "flow"));
t.add(List.of("spmTotal", "pumpOutput"));
t.add(List.of("totalGas", "c1", "c2", "c3"));
t.add(List.of("gainLoss", "tripTank"));
t.add(List.of("incl", "azi"));
t.add(List.of("toolface"));
t.add(List.of("stickSlip", "vibeLateral", "vibeAxial"));
return t;
}
}

View File

@@ -0,0 +1,283 @@
package com.oiusa.las.service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
import com.oiusa.las.index.Pyramid;
import com.oiusa.las.index.RowParser;
import com.oiusa.las.model.LasFile;
import jakarta.enterprise.context.ApplicationScoped;
/**
* Serves decimated curve data for the log-plot view. When the requested index range spans many base
* buckets it aggregates the in-memory {@link Pyramid} (min/max per output point — spikes preserved);
* when zoomed in past base resolution it reads the actual rows for that small window and decimates
* them live. Either way the payload is bounded to the requested pixel width.
*/
@ApplicationScoped
public class CurveDataService {
private static final int MAX_DETAIL_ROWS = 400_000;
private static final double DEFAULT_NULL = -999.25;
public record ReqCurve(String key, String mnemonic, String unit, int column) {}
public record Series(String key, String mnemonic, String unit, int column,
double[] min, double[] max, double dataMin, double dataMax) {}
public record CurveData(String axis, boolean detail, double from, double to, int n,
double[] pos, List<Series> curves) {}
public record CrossData(double[] x, double[] y, double[] c,
double[] xRange, double[] yRange, double[] cRange,
int total, int returned, boolean onBottomFiltered) {}
/**
* Bucket-mean scatter of one channel vs another (e.g. WOB vs ROP), each point colored by a third
* value (depth/time/channel). Optionally restricted to on-bottom buckets — the standard filter for
* a drilling-optimization / founder-point plot.
*/
public CrossData crossplot(LasFile f, int xCol, int yCol, Integer colorCol,
boolean colorDepth, boolean colorTime, boolean onBottomOnly, int max) {
Pyramid pyr = f.pyramid;
max = Math.max(100, Math.min(20000, max));
if (pyr == null || !pyr.ready() || pyr.bucketCount() == 0) {
return new CrossData(new double[0], new double[0], new double[0],
new double[]{0, 1}, new double[]{0, 1}, new double[]{0, 1}, 0, 0, false);
}
int xs = pyr.slotOfColumn(xCol), ys = pyr.slotOfColumn(yCol);
if (xs < 0 || ys < 0) {
return new CrossData(new double[0], new double[0], new double[0],
new double[]{0, 1}, new double[]{0, 1}, new double[]{0, 1}, 0, 0, false);
}
int cs = colorCol != null ? pyr.slotOfColumn(colorCol) : -1;
double[] depthA = pyr.axisArray(true), timeA = pyr.axisArray(false);
boolean ob = onBottomOnly && f.onBottomCol >= 0;
int B = pyr.bucketCount();
List<double[]> pts = new ArrayList<>();
for (int b = 0; b < B; b++) {
if (ob && pyr.onBottom(b) < 0.5) continue;
double x = pyr.meanAt(b, xs), y = pyr.meanAt(b, ys);
if (Float.isNaN((float) x) || Float.isNaN((float) y)) continue;
double c = colorDepth ? depthA[b] : colorTime ? timeA[b] : (cs >= 0 ? pyr.meanAt(b, cs) : depthA[b]);
pts.add(new double[]{x, y, c});
}
int total = pts.size();
// robust axis/color ranges (1st99th pct) so a single garbage spike can't blow up the scatter
double[] xRange = pctRange(pts, 0, 0.01, 0.99);
double[] yRange = pctRange(pts, 1, 0.01, 0.99);
double[] cRange = pctRange(pts, 2, 0.02, 0.98);
int stride = total > max ? (int) Math.ceil((double) total / max) : 1;
int n = (total + stride - 1) / stride;
double[] x = new double[n], y = new double[n], c = new double[n];
int k = 0;
for (int i = 0; i < total && k < n; i += stride) {
double[] p = pts.get(i);
x[k] = p[0]; y[k] = p[1]; c[k] = p[2]; k++;
}
return new CrossData(x, y, c, xRange, yRange, cRange, total, k, ob);
}
private static double[] pctRange(List<double[]> pts, int idx, double lo, double hi) {
double[] a = new double[pts.size()];
int m = 0;
for (double[] p : pts) if (!Double.isNaN(p[idx])) a[m++] = p[idx];
if (m == 0) return new double[]{0, 1};
double[] b = java.util.Arrays.copyOf(a, m);
java.util.Arrays.sort(b);
double mn = b[(int) Math.floor(lo * (m - 1))];
double mx = b[(int) Math.ceil(hi * (m - 1))];
if (mx <= mn) mx = mn + 1;
return new double[]{mn, mx};
}
public CurveData compute(LasFile f, boolean depthAxis, List<ReqCurve> req,
double fromReq, double toReq, int width) {
Pyramid pyr = f.pyramid;
width = Math.max(10, Math.min(4000, width));
String axisName = depthAxis ? "depth" : "time";
if (pyr == null || !pyr.ready() || pyr.bucketCount() == 0 || req.isEmpty()) {
return new CurveData(axisName, false, 0, 0, 0, new double[0], List.of());
}
double[] axis = pyr.axisArray(depthAxis);
int B = pyr.bucketCount();
double aMin = pyr.axisMin(depthAxis);
double aMax = pyr.axisMax(depthAxis);
double from = Double.isNaN(fromReq) ? aMin : Math.max(aMin, fromReq);
double to = Double.isNaN(toReq) ? aMax : Math.min(aMax, toReq);
if (to <= from) { from = aMin; to = aMax; }
int b0 = lowerBound(axis, from);
int b1 = upperBound(axis, to);
if (b1 < b0) b1 = b0;
int bucketsInRange = b1 - b0 + 1;
// Zoomed in past base resolution → read raw rows for crisp detail.
CurveData r = null;
if (bucketsInRange < width) {
r = detail(f, pyr, depthAxis, req, from, to, width, b0, b1, B);
}
if (r == null) {
r = overview(pyr, depthAxis, req, axis, from, to, width, b0, b1);
}
return new CurveData(axisName, r.detail(), r.from(), r.to(), r.n(), r.pos(), r.curves());
}
/* ---------------- overview: aggregate base buckets ---------------- */
private CurveData overview(Pyramid pyr, boolean depthAxis, List<ReqCurve> req, double[] axis,
double from, double to, int width, int b0, int b1) {
int bucketsInRange = b1 - b0 + 1;
int n = Math.min(width, bucketsInRange);
double[] pos = new double[n];
int c = req.size();
double[][] mn = new double[c][n];
double[][] mx = new double[c][n];
int[] slot = new int[c];
double[] dMin = new double[c];
double[] dMax = new double[c];
for (int k = 0; k < c; k++) {
slot[k] = pyr.slotOfColumn(req.get(k).column());
dMin[k] = Double.POSITIVE_INFINITY; dMax[k] = Double.NEGATIVE_INFINITY;
}
for (int j = 0; j < n; j++) {
int bs = b0 + (int) ((long) j * bucketsInRange / n);
int be = b0 + (int) ((long) (j + 1) * bucketsInRange / n);
if (be <= bs) be = bs + 1;
pos[j] = axis[bs];
for (int k = 0; k < c; k++) {
int s = slot[k];
double lo = Double.POSITIVE_INFINITY, hi = Double.NEGATIVE_INFINITY;
if (s >= 0) {
for (int b = bs; b < be; b++) {
float vmn = pyr.minAt(b, s), vmx = pyr.maxAt(b, s);
if (!Float.isNaN(vmn) && vmn < lo) lo = vmn;
if (!Float.isNaN(vmx) && vmx > hi) hi = vmx;
}
}
if (lo == Double.POSITIVE_INFINITY) { mn[k][j] = Double.NaN; mx[k][j] = Double.NaN; }
else {
mn[k][j] = lo; mx[k][j] = hi;
if (lo < dMin[k]) dMin[k] = lo;
if (hi > dMax[k]) dMax[k] = hi;
}
}
}
return assemble("", false, from, to, n, pos, req, mn, mx, dMin, dMax);
}
/* ---------------- detail: read raw rows in a small window ---------------- */
private CurveData detail(LasFile f, Pyramid pyr, boolean depthAxis, List<ReqCurve> req,
double from, double to, int width, int b0, int b1, int B) {
long lineStart = pyr.startLine(b0);
long lineEnd = (b1 + 1 < B) ? pyr.startLine(b1 + 1) : f.index.totalLines();
long rows = lineEnd - lineStart;
if (rows <= 0 || rows > MAX_DETAIL_ROWS) return null;
int axisCol = depthAxis ? (f.holeDepthCol >= 0 ? f.holeDepthCol : f.bitDepthCol) : f.timeCol;
if (axisCol < 0) return null;
// build combined needed columns: axis + requested
TreeSet<Integer> set = new TreeSet<>();
set.add(axisCol);
for (ReqCurve r : req) set.add(r.column());
int[] cols = set.stream().mapToInt(Integer::intValue).toArray();
int axisSlot = indexOf(cols, axisCol);
int[] reqSlot = new int[req.size()];
for (int k = 0; k < req.size(); k++) reqSlot[k] = indexOf(cols, req.get(k).column());
int n = width;
double binW = (to - from) / n;
if (binW <= 0) return null;
int c = req.size();
double[][] mn = new double[c][n];
double[][] mx = new double[c][n];
double[] pos = new double[n];
for (int j = 0; j < n; j++) { pos[j] = from + (j + 0.5) * binW; for (int k = 0; k < c; k++) { mn[k][j] = Double.NaN; mx[k][j] = Double.NaN; } }
double[] dMin = new double[c], dMax = new double[c];
for (int k = 0; k < c; k++) { dMin[k] = Double.POSITIVE_INFINITY; dMax[k] = Double.NEGATIVE_INFINITY; }
double nullVal = parseNull(f.nullValue);
double[] vals = new double[cols.length];
try (FileChannel ch = FileChannel.open(f.path, StandardOpenOption.READ)) {
ch.position(f.index.offsetForLine(lineStart));
BufferedReader rdr = new BufferedReader(
new InputStreamReader(Channels.newInputStream(ch), StandardCharsets.ISO_8859_1), 1 << 20);
long skip = lineStart - f.index.checkpointLine(lineStart);
for (long s = 0; s < skip; s++) if (rdr.readLine() == null) break;
double runMaxDepth = Double.NEGATIVE_INFINITY;
for (long rrow = 0; rrow < rows; rrow++) {
String ln = rdr.readLine();
if (ln == null) break;
RowParser.extract(ln, cols, nullVal, vals);
double a = vals[axisSlot];
if (depthAxis) { // plot vs drilled depth (running max), like the overview
runMaxDepth = Pyramid.advanceDepth(runMaxDepth, a);
a = runMaxDepth == Double.NEGATIVE_INFINITY ? Double.NaN : runMaxDepth;
}
if (Double.isNaN(a)) continue;
int j = (int) ((a - from) / binW);
if (j < 0 || j >= n) continue;
for (int k = 0; k < c; k++) {
double v = vals[reqSlot[k]];
if (Double.isNaN(v)) continue;
if (Double.isNaN(mn[k][j]) || v < mn[k][j]) mn[k][j] = v;
if (Double.isNaN(mx[k][j]) || v > mx[k][j]) mx[k][j] = v;
if (v < dMin[k]) dMin[k] = v;
if (v > dMax[k]) dMax[k] = v;
}
}
} catch (IOException e) {
return null;
}
return assemble("", true, from, to, n, pos, req, mn, mx, dMin, dMax);
}
private CurveData assemble(String axisName, boolean detail, double from, double to, int n, double[] pos,
List<ReqCurve> req, double[][] mn, double[][] mx, double[] dMin, double[] dMax) {
List<Series> series = new ArrayList<>(req.size());
for (int k = 0; k < req.size(); k++) {
ReqCurve r = req.get(k);
double dm = dMin[k] == Double.POSITIVE_INFINITY ? Double.NaN : dMin[k];
double dx = dMax[k] == Double.NEGATIVE_INFINITY ? Double.NaN : dMax[k];
series.add(new Series(r.key(), r.mnemonic(), r.unit(), r.column(), mn[k], mx[k], dm, dx));
}
return new CurveData(axisName, detail, from, to, n, pos, series);
}
/* first bucket with axis[b] >= v */
private static int lowerBound(double[] axis, double v) {
for (int i = 0; i < axis.length; i++) if (!Double.isNaN(axis[i]) && axis[i] >= v) return i;
return axis.length - 1;
}
/* last bucket with axis[b] <= v */
private static int upperBound(double[] axis, double v) {
for (int i = axis.length - 1; i >= 0; i--) if (!Double.isNaN(axis[i]) && axis[i] <= v) return i;
return 0;
}
private static int indexOf(int[] a, int v) {
for (int i = 0; i < a.length; i++) if (a[i] == v) return i;
return -1;
}
private static double parseNull(String nullValue) {
if (nullValue == null || nullValue.isBlank()) return DEFAULT_NULL;
try { return Double.parseDouble(nullValue.trim()); } catch (NumberFormatException e) { return DEFAULT_NULL; }
}
}

View File

@@ -0,0 +1,128 @@
package com.oiusa.las.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import com.oiusa.las.model.LasFile;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
/**
* In-memory registry of {@link LasFile}s plus filesystem policy: where uploads live and which
* roots a local file may be opened from. Opening a local file registers it <em>in place</em>
* (no copy) — essential for the multi-gigabyte logs that already sit on disk.
*/
@ApplicationScoped
public class FileStore {
private static final Logger LOG = Logger.getLogger(FileStore.class);
@Inject
IndexService indexService;
@ConfigProperty(name = "las.data-dir")
String dataDirConfig;
@ConfigProperty(name = "las.allowed-roots")
String allowedRootsConfig;
@ConfigProperty(name = "las.index-stride", defaultValue = "256")
int stride;
@ConfigProperty(name = "las.upload-chunk-size", defaultValue = "16777216")
long uploadChunkSize;
private final ConcurrentHashMap<String, LasFile> files = new ConcurrentHashMap<>();
private Path dataDir;
private Path uploadsDir;
private List<Path> allowedRoots;
@PostConstruct
void init() throws IOException {
dataDir = Path.of(dataDirConfig).toAbsolutePath().normalize();
uploadsDir = dataDir.resolve("uploads");
Files.createDirectories(uploadsDir);
allowedRoots = new ArrayList<>();
for (String r : allowedRootsConfig.split(",")) {
String t = r.trim();
if (!t.isEmpty()) allowedRoots.add(Path.of(t).toAbsolutePath().normalize());
}
// Uploaded files always live under our data dir, so allow it too.
allowedRoots.add(dataDir);
LOG.infof("Data dir: %s | allowed roots: %s", dataDir, allowedRoots);
}
public int stride() { return stride; }
public long uploadChunkSize() { return uploadChunkSize; }
public Path uploadsDir() { return uploadsDir; }
public List<String> allowedRoots() {
List<String> out = new ArrayList<>();
for (Path p : allowedRoots) out.add(p.toString());
return out;
}
/** Validates that {@code candidate} resolves under an allowed root; throws otherwise. */
public Path requireAllowed(Path candidate) {
Path norm = candidate.toAbsolutePath().normalize();
for (Path root : allowedRoots) {
if (norm.startsWith(root)) return norm;
}
throw new SecurityException("Path is outside the allowed roots: " + norm);
}
public Collection<LasFile> all() {
return files.values();
}
public LasFile get(String id) {
return files.get(id);
}
public LasFile require(String id) {
LasFile f = files.get(id);
if (f == null) throw new IllegalArgumentException("No such file: " + id);
return f;
}
/** Register a file already on disk, in place, and start indexing it. */
public LasFile registerLocal(String pathStr) throws IOException {
Path path = requireAllowed(Path.of(pathStr));
if (!Files.isRegularFile(path)) throw new IOException("Not a regular file: " + path);
long size = Files.size(path);
LasFile f = new LasFile(UUID.randomUUID().toString(), path.getFileName().toString(), path, size, false, stride);
files.put(f.id, f);
indexService.index(f);
LOG.infof("Registered local file %s (%,d bytes) as %s", path, size, f.id);
return f;
}
/** Register a freshly-uploaded file (already moved into the uploads dir) and start indexing. */
public LasFile registerUploaded(String name, Path path) throws IOException {
long size = Files.size(path);
LasFile f = new LasFile(UUID.randomUUID().toString(), name, path, size, true, stride);
files.put(f.id, f);
indexService.index(f);
LOG.infof("Registered uploaded file %s (%,d bytes) as %s", name, size, f.id);
return f;
}
/** Forget a file. Deletes bytes only if we own them (uploaded); never touches in-place originals. */
public void remove(String id) throws IOException {
LasFile f = files.remove(id);
if (f != null && f.uploaded) {
Files.deleteIfExists(f.path);
}
}
}

View File

@@ -0,0 +1,231 @@
package com.oiusa.las.service;
import java.io.BufferedInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.eclipse.microprofile.context.ManagedExecutor;
import org.jboss.logging.Logger;
import com.oiusa.las.index.Pyramid;
import com.oiusa.las.index.RowParser;
import com.oiusa.las.model.LasFile;
import com.oiusa.las.model.ResolvedRole;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
/**
* Builds a {@link LasFile}'s line index AND its drilling-curve overview ({@link Pyramid}) in one
* streaming pass: counts lines, records a sparse byte-offset checkpoint every {@code stride} lines,
* parses the header up to {@code ~A}, then for every data row extracts the needed channel columns and
* folds them into min/max base buckets. Memory stays bounded by the 1 MiB read buffer plus the tiny
* sparse index and the (tens-of-MB) pyramid — so 12+ GB files index fine.
*/
@ApplicationScoped
public class IndexService {
private static final Logger LOG = Logger.getLogger(IndexService.class);
private static final int READ_BUF = 1 << 20; // 1 MiB scan buffer
private static final int MAX_HEADER_LINES = 200_000; // safety cap if a file has no ~A marker
private static final long MAX_HEADER_BYTES = 32L << 20;
private static final int MAX_LINE_BYTES = 8 << 20; // never buffer a single line bigger than 8 MiB
private static final int BUCKET_ROWS = 32; // base pyramid resolution (rows per bucket)
private static final double DEFAULT_NULL = -999.25;
@Inject
ManagedExecutor executor;
public void index(LasFile f) {
executor.execute(() -> {
try {
run(f);
} catch (Throwable t) {
f.status = LasFile.Status.ERROR;
f.error = t.getMessage() == null ? t.toString() : t.getMessage();
LOG.errorf(t, "Indexing failed for %s", f.path);
}
});
}
private void run(LasFile f) throws Exception {
f.status = LasFile.Status.INDEXING;
f.index.addCheckpoint(0);
final int stride = f.index.stride();
byte[] buf = new byte[READ_BUF];
long readBase = 0;
long line = 0;
boolean sawByte = false;
boolean inHeader = true;
long headerBytes = 0;
List<String> headerLines = new ArrayList<>();
// reusable per-line accumulator (excludes \n and \r)
byte[] lineBytes = new byte[16 << 10];
int lineLen = 0;
// data-row parsing state, captured once the header ends
Pyramid pyr = null;
int[] cols = null;
double nullVal = DEFAULT_NULL;
double[] slotVals = null;
RowParser.ByteCharSeq seq = new RowParser.ByteCharSeq();
try (InputStream in = new BufferedInputStream(Files.newInputStream(f.path), READ_BUF)) {
int r;
while ((r = in.read(buf)) > 0) {
for (int i = 0; i < r; i++) {
byte b = buf[i];
if (b == '\n') {
if (inHeader) {
String text = new String(lineBytes, 0, lineLen, StandardCharsets.ISO_8859_1);
boolean wasHeader = inHeader;
inHeader = onHeaderLine(f, headerLines, text, line);
headerBytes += text.length() + 1;
if (inHeader && (headerLines.size() >= MAX_HEADER_LINES || headerBytes >= MAX_HEADER_BYTES)) {
finishHeader(f, headerLines, -1);
inHeader = false;
}
if (wasHeader && !inHeader) { // header just ended: latch pyramid state
pyr = f.pyramid;
if (pyr != null) { cols = pyr.columns; slotVals = new double[cols.length]; }
nullVal = parseNull(f.nullValue);
}
} else if (pyr != null) {
seq.set(lineBytes, lineLen);
RowParser.extract(seq, cols, nullVal, slotVals);
pyr.addRow(slotVals, line);
}
line++;
long nextLineStart = readBase + i + 1;
if (line % stride == 0) f.index.addCheckpoint(nextLineStart);
if ((line & 0x3FFF) == 0) { f.indexedLines = line; f.indexedBytes = nextLineStart; }
lineLen = 0;
sawByte = false;
} else if (b != '\r') {
sawByte = true;
if (lineLen < MAX_LINE_BYTES) {
if (lineLen == lineBytes.length) {
lineBytes = java.util.Arrays.copyOf(lineBytes, lineBytes.length * 2);
}
lineBytes[lineLen++] = b;
}
}
}
readBase += r;
f.indexedBytes = readBase;
}
}
// trailing line with no terminating newline
if (sawByte) {
if (inHeader) {
String text = new String(lineBytes, 0, lineLen, StandardCharsets.ISO_8859_1);
onHeaderLine(f, headerLines, text, line);
} else if (pyr != null) {
seq.set(lineBytes, lineLen);
RowParser.extract(seq, cols, nullVal, slotVals);
pyr.addRow(slotVals, line);
}
line++;
}
if (inHeader) finishHeader(f, headerLines, -1);
if (pyr != null) pyr.finish();
f.index.setTotalLines(line);
f.indexedLines = line;
f.indexedBytes = f.sizeBytes;
f.status = LasFile.Status.READY;
LOG.infof("Indexed %s: %,d lines, %,d checkpoints, dataStart=%d, roles=%d, pyramidBuckets=%,d",
f.name, line, f.index.checkpointCount(), f.dataStartLine,
f.roles.size(), pyr != null ? pyr.bucketCount() : 0);
}
private boolean onHeaderLine(LasFile f, List<String> headerLines, String text, long lineNo) {
headerLines.add(text);
String trimmed = text.stripLeading();
if (trimmed.length() >= 2 && trimmed.charAt(0) == '~'
&& (trimmed.charAt(1) == 'A' || trimmed.charAt(1) == 'a')) {
finishHeader(f, headerLines, lineNo + 1);
return false;
}
return true;
}
private void finishHeader(LasFile f, List<String> headerLines, long dataStartLine) {
List<String> dataColumns = List.of();
List<String> sectionLines = headerLines;
if (dataStartLine >= 0 && !headerLines.isEmpty()) {
String aLine = headerLines.get(headerLines.size() - 1);
dataColumns = parseDataColumns(aLine);
sectionLines = headerLines.subList(0, headerLines.size() - 1);
}
LasHeaderParser.Result res = LasHeaderParser.parse(sectionLines, dataColumns);
f.sections = res.sections();
f.curves = res.curves();
f.dataColumns = dataColumns;
f.wrap = res.wrap();
f.nullValue = res.nullValue();
f.wellName = res.wellName();
f.dataStartLine = dataStartLine;
f.headerReady = true;
if (dataStartLine >= 0) setupPyramid(f);
}
/** Resolve drilling roles and allocate the pyramid over the needed columns. */
private void setupPyramid(LasFile f) {
Map<String, ResolvedRole> roles = ChannelRoles.resolve(f.curves);
f.roles = roles;
int timeCol = colOf(roles, "time");
int holeDepthCol = colOf(roles, "holeDepth");
int bitDepthCol = colOf(roles, "bitDepth");
int onBottomCol = colOf(roles, "onBottom");
f.timeCol = timeCol;
f.holeDepthCol = holeDepthCol;
f.bitDepthCol = bitDepthCol;
f.onBottomCol = onBottomCol;
f.hasTimeAxis = timeCol >= 0;
f.hasDepthAxis = holeDepthCol >= 0 || bitDepthCol >= 0;
int[] cols = ChannelRoles.neededColumns(roles, timeCol, holeDepthCol, bitDepthCol, onBottomCol);
if (cols.length == 0) { f.pyramid = null; return; }
int timeSlot = slotOf(cols, timeCol);
int depthSlot = slotOf(cols, holeDepthCol >= 0 ? holeDepthCol : bitDepthCol);
int onBottomSlot = slotOf(cols, onBottomCol);
f.pyramid = new Pyramid(BUCKET_ROWS, cols, timeSlot, depthSlot, onBottomSlot);
}
private static int colOf(Map<String, ResolvedRole> roles, String key) {
ResolvedRole r = roles.get(key);
return r == null ? -1 : r.column();
}
private static int slotOf(int[] cols, int col) {
if (col < 0) return -1;
for (int i = 0; i < cols.length; i++) if (cols[i] == col) return i;
return -1;
}
private static double parseNull(String nullValue) {
if (nullValue == null || nullValue.isBlank()) return DEFAULT_NULL;
try { return Double.parseDouble(nullValue.trim()); } catch (NumberFormatException e) { return DEFAULT_NULL; }
}
private static List<String> parseDataColumns(String aLine) {
String[] tok = aLine.trim().split("\\s+");
List<String> cols = new ArrayList<>(tok.length);
for (int i = 0; i < tok.length; i++) {
if (i == 0 && tok[i].startsWith("~")) continue;
if (!tok[i].isEmpty()) cols.add(tok[i]);
}
return cols;
}
}

View File

@@ -0,0 +1,94 @@
package com.oiusa.las.service;
import java.util.ArrayList;
import java.util.List;
import com.oiusa.las.model.Curve;
import com.oiusa.las.model.HeaderSection;
/**
* Parses the header lines collected before the {@code ~A} data marker into structured
* {@link HeaderSection}s and {@link Curve}s. LAS 2.0 metadata lines look like:
* <pre>
* MNEM.UNIT DATA / API CODE : DESCRIPTION
* WELL. LUSCOMBRE 9H : Well
* TIME .seconds : 1 Time Logged
* </pre>
* Comment lines start with '#'; section headers start with '~'.
*/
public final class LasHeaderParser {
public record Result(List<HeaderSection> sections, List<Curve> curves,
String wrap, String nullValue, String wellName) {}
private LasHeaderParser() {}
/** Parses a single metadata line into [mnemonic, unit, data, description]; nulls if it isn't one. */
public static String[] splitLine(String raw) {
String line = raw;
if (line.isEmpty() || line.charAt(0) == '~' || line.charAt(0) == '#') return null;
int colon = line.indexOf(':');
int dot = line.indexOf('.');
if (dot < 0) return null;
String mnem;
String unit = "";
String data = "";
String desc = "";
// unit runs from just after the first '.' up to the next whitespace
int u = dot + 1;
int unitEnd = u;
while (unitEnd < line.length() && !Character.isWhitespace(line.charAt(unitEnd))) unitEnd++;
mnem = line.substring(0, dot).trim();
unit = line.substring(u, unitEnd).trim();
if (colon >= 0 && colon >= unitEnd) {
data = line.substring(unitEnd, colon).trim();
desc = line.substring(colon + 1).trim();
} else if (colon >= 0) {
desc = line.substring(colon + 1).trim();
} else {
data = line.substring(unitEnd).trim();
}
if (mnem.isEmpty()) return null;
return new String[]{mnem, unit, data, desc};
}
public static Result parse(List<String> headerLines, List<String> dataColumns) {
List<HeaderSection> sections = new ArrayList<>();
List<Curve> curves = new ArrayList<>();
String wrap = null, nullValue = null, wellName = null;
String currentName = null;
List<String> currentLines = new ArrayList<>();
boolean inCurves = false;
int curveCol = 0;
for (String line : headerLines) {
if (!line.isEmpty() && line.charAt(0) == '~') {
if (currentName != null) sections.add(new HeaderSection(currentName, currentLines));
currentName = line.substring(1).trim();
currentLines = new ArrayList<>();
String upper = currentName.toUpperCase();
inCurves = upper.startsWith("CURVE");
continue;
}
if (currentName == null) continue; // pre-section comment banner (e.g. "#Pason DataHub")
currentLines.add(line);
String[] f = splitLine(line);
if (f == null) continue;
if (inCurves) {
curves.add(new Curve(curveCol++, f[0], f[1], f[2], f[3]));
} else {
switch (f[0].toUpperCase()) {
case "WRAP" -> wrap = f[2].isEmpty() ? f[3] : f[2];
case "NULL" -> nullValue = f[2].isEmpty() ? f[3] : f[2];
case "WELL" -> wellName = f[2].isEmpty() ? f[3] : f[2];
default -> { /* keep raw only */ }
}
}
}
if (currentName != null) sections.add(new HeaderSection(currentName, currentLines));
return new Result(sections, curves, wrap, nullValue, wellName);
}
}

View File

@@ -0,0 +1,111 @@
package com.oiusa.las.service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.jboss.logging.Logger;
import com.oiusa.las.model.LasFile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
/**
* Handles resumable chunked uploads. The browser slices a file and PUTs each chunk at a byte
* offset; chunks are written straight to a {@code .part} file via a positioned {@link FileChannel}
* (never buffering the whole file). {@code received} tracks the high-water mark so an interrupted
* upload can resume. On completion the {@code .part} is renamed and handed to {@link FileStore}.
*/
@ApplicationScoped
public class UploadService {
private static final Logger LOG = Logger.getLogger(UploadService.class);
@Inject
FileStore store;
public static final class Session {
public final String id;
public final String name;
public final long size;
public final Path partPath;
public volatile long received;
Session(String id, String name, long size, Path partPath) {
this.id = id;
this.name = name;
this.size = size;
this.partPath = partPath;
}
}
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
public Session init(String name, long size) throws IOException {
String id = UUID.randomUUID().toString();
String safe = sanitize(name);
Path part = store.uploadsDir().resolve(id + "__" + safe + ".part");
Files.deleteIfExists(part);
// Pre-create the file.
try (FileChannel ch = FileChannel.open(part,
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
// nothing; just create
}
Session s = new Session(id, safe, size, part);
sessions.put(id, s);
LOG.infof("Upload init %s (%s, %,d bytes)", id, safe, size);
return s;
}
public Session get(String id) {
Session s = sessions.get(id);
if (s == null) throw new IllegalArgumentException("No such upload: " + id);
return s;
}
/** Write one chunk at {@code offset}; returns the new received high-water mark. */
public long writeChunk(String id, long offset, InputStream body) throws IOException {
Session s = get(id);
long written = 0;
byte[] buf = new byte[1 << 20];
try (FileChannel ch = FileChannel.open(s.partPath, StandardOpenOption.WRITE)) {
ch.position(offset);
int r;
while ((r = body.read(buf)) > 0) {
ch.write(ByteBuffer.wrap(buf, 0, r));
written += r;
}
}
long end = offset + written;
synchronized (s) {
if (end > s.received) s.received = end;
}
return s.received;
}
/** Finalize: rename the .part to its final name and register it for indexing. */
public LasFile complete(String id) throws IOException {
Session s = sessions.remove(id);
if (s == null) throw new IllegalArgumentException("No such upload: " + id);
Path finalPath = store.uploadsDir().resolve(s.id + "__" + s.name);
Files.move(s.partPath, finalPath, StandardCopyOption.REPLACE_EXISTING);
return store.registerUploaded(s.name, finalPath);
}
private static String sanitize(String name) {
if (name == null || name.isBlank()) return "upload.las";
String base = name.replace('\\', '/');
int slash = base.lastIndexOf('/');
if (slash >= 0) base = base.substring(slash + 1);
base = base.replaceAll("[^A-Za-z0-9._ -]", "_").trim();
return base.isEmpty() ? "upload.las" : base;
}
}