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,70 @@
package com.oiusa.las.index;
import java.util.Arrays;
/**
* Sparse byte-offset index over the lines of a file.
*
* <p>One checkpoint is stored every {@code stride} lines: {@code offsets[k]} is the byte
* offset of the first byte of line {@code k * stride} (0-based line numbering). To read an
* arbitrary line we seek to the nearest preceding checkpoint and scan forward at most
* {@code stride-1} lines — so memory stays tiny (e.g. 2.5M lines / stride 256 ≈ 10K longs
* ≈ 80 KB) while random access into a multi-gigabyte file is effectively O(stride).
*
* <p>Checkpoints are appended by a single indexing thread while reader threads look them up
* concurrently; both sides synchronize on this instance, which is cheap because lookups happen
* once per range request, not per line.
*/
public final class LineIndex {
private final int stride;
private long[] offsets = new long[1024];
private int count = 0;
private volatile long totalLines = -1; // -1 until the full scan completes
public LineIndex(int stride) {
if (stride < 1) throw new IllegalArgumentException("stride must be >= 1");
this.stride = stride;
}
public int stride() {
return stride;
}
/** Records that line {@code count * stride} starts at {@code byteOffset}. Must be called in line order. */
public synchronized void addCheckpoint(long byteOffset) {
if (count == offsets.length) {
offsets = Arrays.copyOf(offsets, offsets.length * 2);
}
offsets[count++] = byteOffset;
}
/** Byte offset of the checkpoint at or before {@code line} (i.e. the start of line {@code checkpointLine(line)}). */
public synchronized long offsetForLine(long line) {
if (line < 0) line = 0;
int cp = (int) (line / stride);
if (cp >= count) cp = count - 1;
if (cp < 0) return 0;
return offsets[cp];
}
/** The line number of the checkpoint at or before {@code line}; reading starts here and skips forward. */
public long checkpointLine(long line) {
if (line < 0) line = 0;
long cp = line / stride;
return cp * stride;
}
/** Number of lines that have been indexed so far (checkpoints * stride is a lower bound; this is exact once set). */
public synchronized long checkpointCount() {
return count;
}
public long totalLines() {
return totalLines;
}
public void setTotalLines(long total) {
this.totalLines = total;
}
}

View File

@@ -0,0 +1,67 @@
package com.oiusa.las.index;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import com.oiusa.las.model.LasFile;
/**
* Reads an arbitrary range of lines from a (possibly huge) file using its {@link LineIndex}.
*
* <p>Opens a fresh {@link FileChannel}, seeks to the byte offset of the nearest checkpoint at or
* before the requested start line, then skips the remaining {@code start - checkpointLine} lines
* and reads {@code count} lines. Only the requested window is ever held in memory. Bytes are
* decoded as ISO-8859-1 (1:1, lossless for the ASCII content of LAS files) so no offset can land
* mid-character.
*/
public final class LineReader {
public record Range(long start, List<String> lines, boolean eof) {}
private LineReader() {}
public static Range read(LasFile file, long start, int count) throws IOException {
if (start < 0) start = 0;
if (count < 0) count = 0;
long available = file.availableLines();
if (available >= 0 && start >= available) {
return new Range(start, List.of(), true);
}
long checkpointLine = file.index.checkpointLine(start);
long offset = file.index.offsetForLine(start);
long toSkip = start - checkpointLine;
List<String> lines = new ArrayList<>(Math.min(count, 4096));
boolean eof = false;
try (FileChannel ch = FileChannel.open(file.path, StandardOpenOption.READ)) {
ch.position(offset);
InputStream in = Channels.newInputStream(ch);
// Buffer generously: LAS data rows can be several KB wide.
BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.ISO_8859_1), 1 << 20);
for (long i = 0; i < toSkip; i++) {
if (r.readLine() == null) { eof = true; break; }
}
if (!eof) {
for (int i = 0; i < count; i++) {
String line = r.readLine();
if (line == null) { eof = true; break; }
lines.add(line);
}
}
}
return new Range(start, lines, eof);
}
}

View File

@@ -0,0 +1,188 @@
package com.oiusa.las.index;
import java.util.ArrayList;
import java.util.List;
/**
* A min/max overview of the data, aggregated every {@code K} rows ("base buckets"). For each needed
* channel slot the bucket keeps the min and max over its rows (NULLs ignored), so when the whole
* well is squeezed into a few hundred pixels a 2-second gas kick or a torque transient still shows —
* the cardinal rule of honest log decimation is <b>min/max per pixel, never averaging</b>.
*
* <p>Each bucket also records its position on both axes: {@code axisTime} (first row's time,
* monotonic) and {@code axisDepth} (running-max hole depth = "drilled depth", also monotonic), plus
* the bucket's start line and on-bottom fraction. Switching the plot's x-axis just re-positions the
* same buckets along a different monotonic axis.
*/
public final class Pyramid {
/** Hole depth is monotonic & can't jump; cap per-row growth so a garbage spike can't poison the axis. */
public static final double MAX_DEPTH_JUMP = 5.0; // ft per row (1 s) — ROP up to ~18,000 ft/hr
public static final double MAX_PLAUSIBLE_DEPTH = 60000;
public final int K;
public final int[] columns; // data-column index for each slot (sorted ascending)
public final int slots;
private final int timeSlot, depthSlot, onBottomSlot;
// builder state
private float[] curMin, curMax;
private double[] curSum;
private int[] curCount;
private int rowsInBucket = 0;
private long bucketStartLine = 0;
private double bucketStartTime = Double.NaN;
private double runningMaxDepth = Double.NEGATIVE_INFINITY;
private int onBottomCount = 0;
// accumulated buckets
private final List<float[]> bMin = new ArrayList<>();
private final List<float[]> bMax = new ArrayList<>();
private final List<float[]> bMean = new ArrayList<>(); // per-bucket mean (for crossplots/stats)
private final List<double[]> axis = new ArrayList<>(); // [time, depth] per bucket
private final List<long[]> meta = new ArrayList<>(); // [startLine, onBottomPermille] per bucket
private final float[] gMin, gMax; // global per-slot min/max (for autoscale)
// finished arrays
private float[][] fMin, fMax, fMean;
private double[] fTime, fDepth;
private long[] fStartLine;
private float[] fOnBottom;
private int count = 0;
private volatile boolean ready = false;
public Pyramid(int k, int[] columns, int timeSlot, int depthSlot, int onBottomSlot) {
this.K = Math.max(1, k);
this.columns = columns;
this.slots = columns.length;
this.timeSlot = timeSlot;
this.depthSlot = depthSlot;
this.onBottomSlot = onBottomSlot;
this.gMin = new float[slots];
this.gMax = new float[slots];
java.util.Arrays.fill(gMin, Float.POSITIVE_INFINITY);
java.util.Arrays.fill(gMax, Float.NEGATIVE_INFINITY);
resetBucket();
}
private void resetBucket() {
if (curMin == null) {
curMin = new float[slots];
curMax = new float[slots];
curSum = new double[slots];
curCount = new int[slots];
}
java.util.Arrays.fill(curMin, Float.POSITIVE_INFINITY);
java.util.Arrays.fill(curMax, Float.NEGATIVE_INFINITY);
java.util.Arrays.fill(curSum, 0);
java.util.Arrays.fill(curCount, 0);
rowsInBucket = 0;
onBottomCount = 0;
}
/** Feed one data row's already-extracted slot values (NaN = NULL). */
public void addRow(double[] vals, long lineNo) {
if (rowsInBucket == 0) {
bucketStartLine = lineNo;
bucketStartTime = timeSlot >= 0 ? vals[timeSlot] : lineNo;
}
for (int s = 0; s < slots; s++) {
double v = vals[s];
if (!Double.isNaN(v)) {
float f = (float) v;
if (f < curMin[s]) curMin[s] = f;
if (f > curMax[s]) curMax[s] = f;
if (f < gMin[s]) gMin[s] = f;
if (f > gMax[s]) gMax[s] = f;
curSum[s] += v;
curCount[s]++;
}
}
if (depthSlot >= 0) {
runningMaxDepth = advanceDepth(runningMaxDepth, vals[depthSlot]);
}
if (onBottomSlot >= 0 && vals[onBottomSlot] > 0.5) onBottomCount++;
rowsInBucket++;
if (rowsInBucket >= K) flush();
}
private void flush() {
if (rowsInBucket == 0) return;
float[] mn = new float[slots];
float[] mx = new float[slots];
float[] me = new float[slots];
for (int s = 0; s < slots; s++) {
if (curCount[s] == 0) { mn[s] = Float.NaN; mx[s] = Float.NaN; me[s] = Float.NaN; }
else { mn[s] = curMin[s]; mx[s] = curMax[s]; me[s] = (float) (curSum[s] / curCount[s]); }
}
bMin.add(mn);
bMax.add(mx);
bMean.add(me);
double depth = runningMaxDepth == Double.NEGATIVE_INFINITY ? Double.NaN : runningMaxDepth;
axis.add(new double[]{ bucketStartTime, depth });
long permille = (long) Math.round((onBottomCount * 1000.0) / rowsInBucket);
meta.add(new long[]{ bucketStartLine, permille });
resetBucket();
}
public void finish() {
flush();
count = bMin.size();
fMin = bMin.toArray(new float[0][]);
fMax = bMax.toArray(new float[0][]);
fMean = bMean.toArray(new float[0][]);
fTime = new double[count];
fDepth = new double[count];
fStartLine = new long[count];
fOnBottom = new float[count];
for (int i = 0; i < count; i++) {
fTime[i] = axis.get(i)[0];
fDepth[i] = axis.get(i)[1];
fStartLine[i] = meta.get(i)[0];
fOnBottom[i] = meta.get(i)[1] / 1000f;
}
bMin.clear(); bMax.clear(); bMean.clear(); axis.clear(); meta.clear();
ready = true;
}
public boolean ready() { return ready; }
public int bucketCount() { return count; }
public int slotOfColumn(int col) {
for (int s = 0; s < slots; s++) if (columns[s] == col) return s;
return -1;
}
public float minAt(int bucket, int slot) { return fMin[bucket][slot]; }
public float maxAt(int bucket, int slot) { return fMax[bucket][slot]; }
public float meanAt(int bucket, int slot) { return fMean[bucket][slot]; }
public double[] axisArray(boolean depthAxis) { return depthAxis ? fDepth : fTime; }
public long startLine(int bucket) { return fStartLine[bucket]; }
public float onBottom(int bucket) { return fOnBottom[bucket]; }
public float globalMin(int slot) { return gMin[slot] == Float.POSITIVE_INFINITY ? Float.NaN : gMin[slot]; }
public float globalMax(int slot) { return gMax[slot] == Float.NEGATIVE_INFINITY ? Float.NaN : gMax[slot]; }
public double axisMin(boolean depthAxis) {
double[] a = depthAxis ? fDepth : fTime;
for (double v : a) if (!Double.isNaN(v)) return v;
return 0;
}
public double axisMax(boolean depthAxis) {
double[] a = depthAxis ? fDepth : fTime;
for (int i = a.length - 1; i >= 0; i--) if (!Double.isNaN(a[i])) return a[i];
return 0;
}
/**
* Robust monotonic hole-depth tracker: seeds on the first plausible reading, then only advances
* by at most {@link #MAX_DEPTH_JUMP} per row, so a single bad sample can't pin the axis.
*/
public static double advanceDepth(double runningMax, double d) {
if (Double.isNaN(d) || d < 0 || d > MAX_PLAUSIBLE_DEPTH) return runningMax;
if (runningMax == Double.NEGATIVE_INFINITY) return d; // seed
if (d > runningMax && d <= runningMax + MAX_DEPTH_JUMP) return d;
return runningMax;
}
}

View File

@@ -0,0 +1,103 @@
package com.oiusa.las.index;
/**
* Extracts a chosen, sorted subset of whitespace-delimited columns from a data row, parsing each as
* a double (NULL sentinel and non-numeric tokens become {@code NaN}). Works over any
* {@link CharSequence} — a {@code String} (detail reads) or a zero-copy byte wrapper (indexing) —
* and avoids per-token substring allocation, since it runs ~40 columns × millions of rows.
*/
public final class RowParser {
private RowParser() {}
/** Zero-copy ISO-8859-1 view over a byte buffer, so the indexer can parse without allocating Strings. */
public static final class ByteCharSeq implements CharSequence {
private byte[] b;
private int len;
public void set(byte[] buf, int length) { this.b = buf; this.len = length; }
public int length() { return len; }
public char charAt(int i) { return (char) (b[i] & 0xFF); }
public CharSequence subSequence(int s, int e) { return new String(b, s, e - s, java.nio.charset.StandardCharsets.ISO_8859_1); }
public String toString() { return new String(b, 0, len, java.nio.charset.StandardCharsets.ISO_8859_1); }
}
/**
* @param line the row text
* @param columns sorted-ascending 0-based column indices to extract
* @param nullValue LAS NULL sentinel (e.g. -999.25); matching values map to NaN
* @param out output array, length == columns.length; filled with values or NaN
*/
public static void extract(CharSequence line, int[] columns, double nullValue, double[] out) {
for (int i = 0; i < out.length; i++) out[i] = Double.NaN;
final int n = line.length();
int ptr = 0; // index into columns[]
int tok = 0; // current token index
int i = 0;
while (i < n && ptr < columns.length) {
// skip whitespace
while (i < n && isWs(line.charAt(i))) i++;
if (i >= n) break;
int start = i;
while (i < n && !isWs(line.charAt(i))) i++;
int end = i; // token = [start, end)
if (tok == columns[ptr]) {
double v = parse(line, start, end);
if (!Double.isNaN(v) && Math.abs(v - nullValue) < 1e-6) v = Double.NaN;
out[ptr] = v;
ptr++;
}
tok++;
}
}
private static boolean isWs(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }
/** Lightweight double parser; returns NaN on any non-numeric content (dates, flags, etc.). */
static double parse(CharSequence s, int start, int end) {
int i = start;
if (i >= end) return Double.NaN;
boolean neg = false;
char c = s.charAt(i);
if (c == '+' || c == '-') { neg = c == '-'; i++; }
long mant = 0;
int digits = 0, scale = 0;
boolean dot = false;
for (; i < end; i++) {
c = s.charAt(i);
if (c >= '0' && c <= '9') {
if (digits < 16) { mant = mant * 10 + (c - '0'); if (dot) scale++; }
else { if (!dot) scale--; } // beyond long precision: track magnitude only
digits++;
} else if (c == '.') {
if (dot) return Double.NaN;
dot = true;
} else if (c == 'e' || c == 'E') {
return parseWithExp(s, start, end);
} else {
return Double.NaN; // non-numeric token (e.g. an ISO timestamp)
}
}
if (digits == 0) return Double.NaN;
double val = mant * pow10(-scale);
return neg ? -val : val;
}
private static double parseWithExp(CharSequence s, int start, int end) {
// rare path; correctness over speed
try {
return Double.parseDouble(s.subSequence(start, end).toString());
} catch (NumberFormatException e) {
return Double.NaN;
}
}
private static final double[] POW10 = new double[]{
1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16
};
private static double pow10(int e) {
if (e >= 0 && e < POW10.length) return POW10[e];
if (e < 0 && -e < POW10.length) return 1.0 / POW10[-e];
return Math.pow(10, e);
}
}

View File

@@ -0,0 +1,14 @@
package com.oiusa.las.model;
/**
* One channel from the LAS {@code ~CURVE} section, e.g.
* {@code TIME .seconds : 1 Time Logged}.
*
* @param column 0-based position of this curve among the data columns
* @param mnemonic short name (text before the first '.')
* @param unit unit of measure (between '.' and the next whitespace), may be empty
* @param apiCode API code / data field (between the unit and the ':'), often blank
* @param description free-text description (after the ':')
*/
public record Curve(int column, String mnemonic, String unit, String apiCode, String description) {
}

View File

@@ -0,0 +1,13 @@
package com.oiusa.las.model;
import java.util.List;
/**
* A LAS header section (a line starting with '~' plus the raw lines beneath it,
* e.g. {@code ~WELL INFORMATION BLOCK}). Lines are kept verbatim for display.
*
* @param name the section title without the leading '~' (e.g. "WELL INFORMATION BLOCK")
* @param lines the raw lines belonging to the section, excluding the header line itself
*/
public record HeaderSection(String name, List<String> lines) {
}

View File

@@ -0,0 +1,74 @@
package com.oiusa.las.model;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import com.oiusa.las.index.LineIndex;
import com.oiusa.las.index.Pyramid;
/**
* A LAS file known to the server, plus its progressively-built index and parsed header.
*
* <p>Fields written by the background indexer are {@code volatile} so REST threads observe
* progress without locking. The header (sections/curves) becomes visible as soon as the
* indexer crosses the {@code ~A} data marker; the full {@link #totalLines} fills in when the
* one-pass scan completes.
*/
public final class LasFile {
public enum Status { REGISTERED, INDEXING, READY, ERROR }
public final String id;
public volatile String name;
public final Path path;
public final long sizeBytes;
/** true if the bytes were uploaded into our data dir (safe to delete); false if opened in-place. */
public final boolean uploaded;
public final LineIndex index;
public volatile Status status = Status.REGISTERED;
public volatile String error;
// --- header (available once headerReady) ---
public volatile boolean headerReady = false;
public volatile List<HeaderSection> sections = List.of();
public volatile List<Curve> curves = List.of();
/** Column names parsed from the {@code ~A} line, in data order. */
public volatile List<String> dataColumns = List.of();
/** 0-based line number of the first data row (line after {@code ~A}); -1 if unknown. */
public volatile long dataStartLine = -1;
public volatile String wrap; // YES / NO
public volatile String nullValue; // e.g. -999.25
public volatile String wellName;
// --- drilling roles + curve overview (for the log-plot view) ---
public volatile Map<String, ResolvedRole> roles = Map.of();
public volatile Pyramid pyramid;
public volatile boolean hasTimeAxis = false;
public volatile boolean hasDepthAxis = false;
public volatile int timeCol = -1;
public volatile int holeDepthCol = -1;
public volatile int bitDepthCol = -1;
public volatile int onBottomCol = -1;
// --- index progress ---
public volatile long indexedLines = 0;
public volatile long indexedBytes = 0;
public LasFile(String id, String name, Path path, long sizeBytes, boolean uploaded, int stride) {
this.id = id;
this.name = name;
this.path = path;
this.sizeBytes = sizeBytes;
this.uploaded = uploaded;
this.index = new LineIndex(stride);
}
/** Lines safe to read right now: the full count once READY, otherwise what's been indexed so far. */
public long availableLines() {
long total = index.totalLines();
if (total >= 0) return total;
return indexedLines;
}
}

View File

@@ -0,0 +1,16 @@
package com.oiusa.las.model;
/**
* A drilling "role" (ROP, WOB, total gas, stick-slip, …) bound to a concrete curve in a file.
*
* @param key stable role id (e.g. "rop")
* @param label human label (e.g. "ROP")
* @param group track group ("mechanics", "hydraulics", "gas", "directional", "index")
* @param mnemonic the LAS mnemonic this role resolved to
* @param unit the resolved curve's unit
* @param description the resolved curve's description
* @param column 0-based data column index
*/
public record ResolvedRole(String key, String label, String group, String mnemonic,
String unit, String description, int column) {
}

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;
}
}

View File

@@ -0,0 +1,156 @@
package com.oiusa.las.web;
import java.util.ArrayList;
import java.util.List;
import com.oiusa.las.index.Pyramid;
import com.oiusa.las.model.LasFile;
import com.oiusa.las.model.ResolvedRole;
import com.oiusa.las.service.ChannelRoles;
import com.oiusa.las.service.CurveDataService;
import com.oiusa.las.service.FileStore;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
/** Roles + decimated curve data that power the drilling log-plot view. */
@jakarta.ws.rs.Path("/api/files")
@Produces(MediaType.APPLICATION_JSON)
public class CurveResource {
@Inject
FileStore store;
@Inject
CurveDataService curveData;
@GET
@jakarta.ws.rs.Path("/{id}/roles")
public Response roles(@PathParam("id") String id) {
LasFile f = store.get(id);
if (f == null) return Response.status(404).entity(error("No such file: " + id)).build();
Pyramid pyr = f.pyramid;
boolean ready = pyr != null && pyr.ready();
List<Dtos.RoleInfo> roles = new ArrayList<>();
for (ResolvedRole r : f.roles.values()) {
Double dMin = null, dMax = null;
if (ready) {
int slot = pyr.slotOfColumn(r.column());
if (slot >= 0) {
float gmn = pyr.globalMin(slot), gmx = pyr.globalMax(slot);
if (!Float.isNaN(gmn)) dMin = (double) gmn;
if (!Float.isNaN(gmx)) dMax = (double) gmx;
}
}
double[] sc = ChannelRoles.defaultScale(r.key());
roles.add(new Dtos.RoleInfo(r.key(), r.label(), r.group(), r.mnemonic(),
r.unit(), r.description(), r.column(), dMin, dMax, sc[0], sc[1]));
}
// Only advertise an axis once we know its real extent is non-degenerate. A file whose depth
// channel is broken (e.g. stuck/zero) collapses to a single value — don't offer it as an axis.
boolean hasTime = f.hasTimeAxis;
boolean hasDepth = f.hasDepthAxis;
Dtos.AxisExtent timeExt = null, depthExt = null;
if (ready) {
if (f.hasTimeAxis) {
double a = pyr.axisMin(false), b = pyr.axisMax(false);
if (b - a > 0) timeExt = new Dtos.AxisExtent(a, b); else hasTime = false;
}
if (f.hasDepthAxis) {
double a = pyr.axisMin(true), b = pyr.axisMax(true);
if (b - a > 50) depthExt = new Dtos.AxisExtent(a, b); else hasDepth = false;
}
}
return Response.ok(new Dtos.RolesResponse(ready, hasTime, hasDepth,
timeExt, depthExt, roles, ChannelRoles.defaultTracks())).build();
}
@GET
@jakarta.ws.rs.Path("/{id}/curve-data")
@Blocking
public Response curveDataEndpoint(@PathParam("id") String id,
@QueryParam("axis") String axis,
@QueryParam("curves") String curves,
@QueryParam("from") Double from,
@QueryParam("to") Double to,
@QueryParam("width") Integer width) {
LasFile f = store.get(id);
if (f == null) return Response.status(404).entity(error("No such file: " + id)).build();
boolean depthAxis = "depth".equalsIgnoreCase(axis);
List<CurveDataService.ReqCurve> req = new ArrayList<>();
if (curves != null && !curves.isBlank()) {
for (String key : curves.split(",")) {
ResolvedRole r = f.roles.get(key.trim());
if (r != null) req.add(new CurveDataService.ReqCurve(r.key(), r.mnemonic(), r.unit(), r.column()));
}
}
double fromD = from == null ? Double.NaN : from;
double toD = to == null ? Double.NaN : to;
int w = width == null ? 800 : width;
CurveDataService.CurveData d = curveData.compute(f, depthAxis, req, fromD, toD, w);
return Response.ok(toDto(d)).build();
}
@GET
@jakarta.ws.rs.Path("/{id}/crossplot")
@Blocking
public Response crossplot(@PathParam("id") String id,
@QueryParam("x") String x,
@QueryParam("y") String y,
@QueryParam("color") String color,
@QueryParam("onBottom") @jakarta.ws.rs.DefaultValue("false") boolean onBottom,
@QueryParam("max") Integer max) {
LasFile f = store.get(id);
if (f == null) return Response.status(404).entity(error("No such file: " + id)).build();
ResolvedRole rx = f.roles.get(x), ry = f.roles.get(y);
if (rx == null || ry == null) return Response.status(400).entity(error("x and y must be resolved roles")).build();
boolean colorDepth = "depth".equalsIgnoreCase(color);
boolean colorTime = "time".equalsIgnoreCase(color);
Integer colorCol = null;
if (!colorDepth && !colorTime && color != null) {
ResolvedRole rc = f.roles.get(color);
if (rc != null) colorCol = rc.column();
}
if (color == null) colorDepth = f.hasDepthAxis; // sensible default
CurveDataService.CrossData d = curveData.crossplot(f, rx.column(), ry.column(),
colorCol, colorDepth, colorTime, onBottom, max == null ? 5000 : max);
return Response.ok(new Dtos.CrossDataDto(toList(d.x()), toList(d.y()), toList(d.c()),
d.xRange(), d.yRange(), d.cRange(), d.total(), d.returned(), d.onBottomFiltered())).build();
}
private static Dtos.CurveDataDto toDto(CurveDataService.CurveData d) {
List<Dtos.CurveSeriesDto> curves = new ArrayList<>();
for (CurveDataService.Series s : d.curves()) {
curves.add(new Dtos.CurveSeriesDto(s.key(), s.mnemonic(), s.unit(), s.column(),
toList(s.min()), toList(s.max()),
nan(s.dataMin()), nan(s.dataMax())));
}
return new Dtos.CurveDataDto(d.axis(), d.detail(), d.from(), d.to(), d.n(), toList(d.pos()), curves);
}
/** double[] -> List<Double> with NaN mapped to null (so the JSON is valid and gaps are explicit). */
private static List<Double> toList(double[] a) {
List<Double> out = new ArrayList<>(a.length);
for (double v : a) out.add(Double.isNaN(v) ? null : v);
return out;
}
private static Double nan(double v) { return Double.isNaN(v) ? null : v; }
private static java.util.Map<String, String> error(String msg) {
return java.util.Map.of("error", msg == null ? "error" : msg);
}
}

View File

@@ -0,0 +1,78 @@
package com.oiusa.las.web;
import java.util.List;
import com.oiusa.las.model.Curve;
import com.oiusa.las.model.HeaderSection;
import com.oiusa.las.model.LasFile;
/** Request/response shapes for the REST API. Records serialize cleanly via Jackson. */
public final class Dtos {
private Dtos() {}
public record FileSummary(
String id, String name, long sizeBytes, String status, String error,
boolean uploaded, boolean headerReady,
long indexedLines, long indexedBytes, long totalLines, long availableLines,
long dataStartLine, int curveCount, String wellName) {
public static FileSummary of(LasFile f) {
return new FileSummary(
f.id, f.name, f.sizeBytes, f.status.name(), f.error,
f.uploaded, f.headerReady,
f.indexedLines, f.indexedBytes, f.index.totalLines(), f.availableLines(),
f.dataStartLine, f.curves.size(), f.wellName);
}
}
public record FileMeta(
FileSummary summary,
List<HeaderSection> sections,
List<Curve> curves,
List<String> dataColumns,
String wrap, String nullValue) {
public static FileMeta of(LasFile f) {
return new FileMeta(FileSummary.of(f), f.sections, f.curves, f.dataColumns, f.wrap, f.nullValue);
}
}
public record LinesResponse(long start, List<String> lines, boolean eof, long availableLines) {}
public record ConfigResponse(List<String> allowedRoots, String homeDir,
long uploadChunkSize, int indexStride) {}
public record BrowseEntry(String name, String path, boolean dir, long sizeBytes, boolean looksLikeLas) {}
public record BrowseResponse(String dir, String parent, List<BrowseEntry> entries) {}
public record LocalRegisterRequest(String path) {}
public record UploadInitRequest(String name, long size) {}
public record UploadInitResponse(String uploadId, long received, long chunkSize) {}
public record UploadStatusResponse(String uploadId, long received, long size) {}
// ---- log-plot / curve roles ----
public record AxisExtent(Double min, Double max) {}
public record RoleInfo(String key, String label, String group, String mnemonic,
String unit, String description, int column,
Double dataMin, Double dataMax, double defMin, double defMax) {}
public record RolesResponse(boolean ready, boolean hasTimeAxis, boolean hasDepthAxis,
AxisExtent timeExtent, AxisExtent depthExtent,
List<RoleInfo> roles, List<List<String>> defaultTracks) {}
public record CurveSeriesDto(String key, String mnemonic, String unit, int column,
List<Double> min, List<Double> max, Double dataMin, Double dataMax) {}
public record CurveDataDto(String axis, boolean detail, double from, double to, int n,
List<Double> pos, List<CurveSeriesDto> curves) {}
public record CrossDataDto(List<Double> x, List<Double> y, List<Double> c,
double[] xRange, double[] yRange, double[] cRange,
int total, int returned, boolean onBottomFiltered) {}
}

View File

@@ -0,0 +1,139 @@
package com.oiusa.las.web;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
import com.oiusa.las.model.LasFile;
import com.oiusa.las.service.FileStore;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
// NOTE: jakarta.ws.rs.Path clashes with java.nio.file.Path, so the JAX-RS annotation is used
// fully-qualified as @jakarta.ws.rs.Path and java.nio.file.Path is imported normally.
@jakarta.ws.rs.Path("/api/files")
@Produces(MediaType.APPLICATION_JSON)
public class FileResource {
@Inject
FileStore store;
@GET
public List<Dtos.FileSummary> list() {
List<Dtos.FileSummary> out = new ArrayList<>();
for (LasFile f : store.all()) out.add(Dtos.FileSummary.of(f));
out.sort(Comparator.comparing(Dtos.FileSummary::name, String.CASE_INSENSITIVE_ORDER));
return out;
}
@GET
@jakarta.ws.rs.Path("/config")
public Dtos.ConfigResponse config() {
return new Dtos.ConfigResponse(store.allowedRoots(), System.getProperty("user.home"),
store.uploadChunkSize(), store.stride());
}
/** Server-side directory listing constrained to the allowed roots, for the file picker. */
@GET
@jakarta.ws.rs.Path("/browse")
@Blocking
public Response browse(@QueryParam("dir") String dir) {
try {
Path target = (dir == null || dir.isBlank())
? Path.of(System.getProperty("user.home"))
: Path.of(dir);
target = store.requireAllowed(target);
if (!Files.isDirectory(target)) {
return Response.status(400).entity(error("Not a directory: " + target)).build();
}
List<Dtos.BrowseEntry> entries = new ArrayList<>();
try (Stream<Path> s = Files.list(target)) {
s.forEach(p -> {
try {
boolean isDir = Files.isDirectory(p);
long size = isDir ? 0 : Files.size(p);
String n = p.getFileName().toString();
entries.add(new Dtos.BrowseEntry(n, p.toString(), isDir, size, looksLikeLas(n)));
} catch (IOException ignore) { /* skip unreadable entries */ }
});
}
entries.sort(Comparator
.comparing(Dtos.BrowseEntry::dir).reversed()
.thenComparing(Dtos.BrowseEntry::name, String.CASE_INSENSITIVE_ORDER));
Path parent = target.getParent();
String parentStr = (parent != null && isUnderAnyRoot(parent)) ? parent.toString() : null;
return Response.ok(new Dtos.BrowseResponse(target.toString(), parentStr, entries)).build();
} catch (SecurityException e) {
return Response.status(403).entity(error(e.getMessage())).build();
} catch (IOException e) {
return Response.status(400).entity(error(e.getMessage())).build();
}
}
@GET
@jakarta.ws.rs.Path("/{id}")
public Dtos.FileMeta meta(@PathParam("id") String id) {
return Dtos.FileMeta.of(store.require(id));
}
@POST
@jakarta.ws.rs.Path("/local")
@Consumes(MediaType.APPLICATION_JSON)
@Blocking
public Response registerLocal(Dtos.LocalRegisterRequest req) {
if (req == null || req.path() == null || req.path().isBlank()) {
return Response.status(400).entity(error("path is required")).build();
}
try {
LasFile f = store.registerLocal(req.path());
return Response.ok(Dtos.FileSummary.of(f)).build();
} catch (SecurityException e) {
return Response.status(403).entity(error(e.getMessage())).build();
} catch (IOException e) {
return Response.status(400).entity(error(e.getMessage())).build();
}
}
@DELETE
@jakarta.ws.rs.Path("/{id}")
@Blocking
public Response delete(@PathParam("id") String id) {
try {
store.remove(id);
return Response.noContent().build();
} catch (IOException e) {
return Response.status(500).entity(error(e.getMessage())).build();
}
}
private boolean isUnderAnyRoot(Path p) {
Path norm = p.toAbsolutePath().normalize();
for (String r : store.allowedRoots()) {
if (norm.startsWith(Path.of(r).toAbsolutePath().normalize())) return true;
}
return false;
}
private static boolean looksLikeLas(String name) {
String n = name.toLowerCase();
return n.endsWith(".las") || n.endsWith(".asc") || n.endsWith(".txt");
}
private static java.util.Map<String, String> error(String msg) {
return java.util.Map.of("error", msg == null ? "error" : msg);
}
}

View File

@@ -0,0 +1,51 @@
package com.oiusa.las.web;
import java.io.IOException;
import com.oiusa.las.index.LineReader;
import com.oiusa.las.model.LasFile;
import com.oiusa.las.service.FileStore;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@jakarta.ws.rs.Path("/api/files")
@Produces(MediaType.APPLICATION_JSON)
public class LinesResource {
/** Hard cap on a single range request so the browser is never handed an unbounded payload. */
private static final int MAX_COUNT = 2000;
@Inject
FileStore store;
@GET
@jakarta.ws.rs.Path("/{id}/lines")
@Blocking
public Response lines(@PathParam("id") String id,
@QueryParam("start") @DefaultValue("0") long start,
@QueryParam("count") @DefaultValue("200") int count) {
LasFile f = store.get(id);
if (f == null) return Response.status(404).entity(error("No such file: " + id)).build();
if (count < 0) count = 0;
if (count > MAX_COUNT) count = MAX_COUNT;
try {
LineReader.Range range = LineReader.read(f, start, count);
return Response.ok(new Dtos.LinesResponse(
range.start(), range.lines(), range.eof(), f.availableLines())).build();
} catch (IOException e) {
return Response.status(500).entity(error(e.getMessage())).build();
}
}
private static java.util.Map<String, String> error(String msg) {
return java.util.Map.of("error", msg == null ? "error" : msg);
}
}

View File

@@ -0,0 +1,109 @@
package com.oiusa.las.web;
import java.io.BufferedReader;
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.Locale;
import org.eclipse.microprofile.context.ManagedExecutor;
import org.jboss.logging.Logger;
import com.oiusa.las.model.LasFile;
import com.oiusa.las.service.FileStore;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
/**
* Case-insensitive substring search streamed over SSE: scans the file once and emits a {@code match}
* event per hit (capped at {@code max}), periodic {@code progress} events, and a final {@code done}.
* Sequential single-pass scan = bounded memory even for 12 GB files. The client closes the stream to
* cancel an in-flight search.
*/
@jakarta.ws.rs.Path("/api/files")
public class SearchResource {
private static final Logger LOG = Logger.getLogger(SearchResource.class);
private static final int SNIPPET_MAX = 2000;
@Inject
FileStore store;
@Inject
ManagedExecutor executor;
public record Match(long line, String text) {}
public record Progress(long scanned, int matches) {}
public record Done(long scanned, int matches, boolean truncated) {}
@GET
@jakarta.ws.rs.Path("/{id}/search")
@Produces(MediaType.SERVER_SENT_EVENTS)
public void search(@PathParam("id") String id,
@QueryParam("q") String q,
@QueryParam("max") @DefaultValue("500") int max,
@Context SseEventSink sink,
@Context Sse sse) {
final LasFile f = store.get(id);
if (f == null || q == null || q.isEmpty()) {
sink.send(sse.newEventBuilder().name("done")
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(Done.class, new Done(0, 0, false)).build());
sink.close();
return;
}
final int cap = Math.max(1, Math.min(10000, max));
final String needle = q.toLowerCase(Locale.ROOT);
executor.execute(() -> runSearch(f, sink, sse, needle, cap));
}
private void runSearch(LasFile f, SseEventSink sink, Sse sse, String needle, int cap) {
long line = 0;
int matches = 0;
boolean truncated = false;
try (FileChannel ch = FileChannel.open(f.path, StandardOpenOption.READ)) {
BufferedReader r = new BufferedReader(
new InputStreamReader(Channels.newInputStream(ch), StandardCharsets.ISO_8859_1), 1 << 20);
String ln;
while ((ln = r.readLine()) != null) {
if (sink.isClosed()) return;
if (ln.toLowerCase(Locale.ROOT).contains(needle)) {
String snippet = ln.length() > SNIPPET_MAX ? ln.substring(0, SNIPPET_MAX) + "" : ln;
sink.send(sse.newEventBuilder().name("match")
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(Match.class, new Match(line, snippet)).build())
.toCompletableFuture().get();
if (++matches >= cap) { truncated = true; break; }
}
line++;
if ((line & 0x3FFFF) == 0) { // ~every 262k lines
sink.send(sse.newEventBuilder().name("progress")
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(Progress.class, new Progress(line, matches)).build());
}
}
if (!sink.isClosed()) {
sink.send(sse.newEventBuilder().name("done")
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(Done.class, new Done(line, matches, truncated)).build());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
LOG.debugf(e, "search ended for %s", f.id);
} finally {
try { sink.close(); } catch (Exception ignore) { }
}
}
}

View File

@@ -0,0 +1,124 @@
package com.oiusa.las.web;
import java.io.BufferedReader;
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 org.eclipse.microprofile.context.ManagedExecutor;
import org.jboss.logging.Logger;
import com.oiusa.las.model.LasFile;
import com.oiusa.las.service.FileStore;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
/**
* Streams a file's lines to the browser over Server-Sent Events, starting at {@code start} and
* pacing {@code batch} lines every {@code intervalMs}. One {@link BufferedReader} is opened at the
* start offset (located via the sparse index) and advanced sequentially, so even a 12 GB file
* streams with a constant, tiny memory footprint. The loop stops on EOF or when the client
* disconnects (detected because the awaited {@code send} fails).
*/
@jakarta.ws.rs.Path("/api/files")
public class StreamResource {
private static final Logger LOG = Logger.getLogger(StreamResource.class);
@Inject
FileStore store;
@Inject
ManagedExecutor executor;
public record StreamBatch(long start, List<String> lines, boolean eof, long availableLines, long seq) {}
@GET
@jakarta.ws.rs.Path("/{id}/stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
public void stream(@PathParam("id") String id,
@QueryParam("start") @DefaultValue("0") long start,
@QueryParam("batch") @DefaultValue("40") int batch,
@QueryParam("intervalMs") @DefaultValue("80") long intervalMs,
@Context SseEventSink sink,
@Context Sse sse) {
final LasFile f = store.get(id);
if (f == null) {
// "fail" (not "error") so it doesn't collide with EventSource's native error event.
sink.send(sse.newEventBuilder().name("fail").data(String.class, "No such file: " + id).build());
sink.close();
return;
}
final int b = Math.max(1, Math.min(1000, batch));
final long delay = Math.max(0, Math.min(5000, intervalMs));
final long startLine = Math.max(0, start);
executor.execute(() -> runStream(f, sink, sse, startLine, b, delay));
}
private void runStream(LasFile f, SseEventSink sink, Sse sse, long startLine, int batch, long delay) {
long line = startLine;
long seq = 0;
try (FileChannel ch = FileChannel.open(f.path, StandardOpenOption.READ)) {
ch.position(f.index.offsetForLine(startLine));
BufferedReader r = new BufferedReader(
new InputStreamReader(Channels.newInputStream(ch), StandardCharsets.ISO_8859_1), 1 << 20);
long skip = startLine - f.index.checkpointLine(startLine);
for (long i = 0; i < skip; i++) {
if (r.readLine() == null) break;
}
while (!sink.isClosed()) {
List<String> lines = new ArrayList<>(batch);
boolean eof = false;
for (int i = 0; i < batch; i++) {
String ln = r.readLine();
if (ln == null) { eof = true; break; }
lines.add(ln);
}
if (!lines.isEmpty()) {
StreamBatch payload = new StreamBatch(line, lines, eof, f.availableLines(), seq++);
// Awaiting the send applies backpressure and surfaces client disconnects.
sink.send(sse.newEventBuilder()
.name("lines")
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(StreamBatch.class, payload)
.build())
.toCompletableFuture().get();
line += lines.size();
}
if (eof) {
if (!sink.isClosed()) {
sink.send(sse.newEventBuilder().name("eof")
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(StreamBatch.class, new StreamBatch(line, List.of(), true, f.availableLines(), seq))
.build());
}
break;
}
if (delay > 0) Thread.sleep(delay);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
// Client disconnect or IO error: just stop. (Common and expected on pause/close.)
LOG.debugf(e, "stream ended for %s", f.id);
} finally {
try { sink.close(); } catch (Exception ignore) { }
}
}
}

View File

@@ -0,0 +1,99 @@
package com.oiusa.las.web;
import java.io.IOException;
import java.io.InputStream;
import com.oiusa.las.model.LasFile;
import com.oiusa.las.service.UploadService;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
/**
* Resumable chunked upload API:
* <ol>
* <li>{@code POST /api/uploads/init} {name,size} → uploadId</li>
* <li>{@code PUT /api/uploads/{id}/chunk?offset=N} (octet-stream body) → received high-water mark</li>
* <li>{@code GET /api/uploads/{id}} → {received,size} (to resume after an interruption)</li>
* <li>{@code POST /api/uploads/{id}/complete} → registers the file and starts indexing</li>
* </ol>
*/
@jakarta.ws.rs.Path("/api/uploads")
@Produces(MediaType.APPLICATION_JSON)
public class UploadResource {
@Inject
UploadService uploads;
@POST
@jakarta.ws.rs.Path("/init")
@Consumes(MediaType.APPLICATION_JSON)
@Blocking
public Response init(Dtos.UploadInitRequest req) {
if (req == null || req.name() == null || req.size() < 0) {
return Response.status(400).entity(error("name and size are required")).build();
}
try {
UploadService.Session s = uploads.init(req.name(), req.size());
return Response.ok(new Dtos.UploadInitResponse(s.id, s.received, 0)).build();
} catch (IOException e) {
return Response.status(500).entity(error(e.getMessage())).build();
}
}
@PUT
@jakarta.ws.rs.Path("/{id}/chunk")
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
@Blocking
public Response chunk(@PathParam("id") String id,
@QueryParam("offset") @DefaultValue("0") long offset,
InputStream body) {
try {
long received = uploads.writeChunk(id, offset, body);
return Response.ok(new Dtos.UploadStatusResponse(id, received, -1)).build();
} catch (IllegalArgumentException e) {
return Response.status(404).entity(error(e.getMessage())).build();
} catch (IOException e) {
return Response.status(500).entity(error(e.getMessage())).build();
}
}
@GET
@jakarta.ws.rs.Path("/{id}")
public Response status(@PathParam("id") String id) {
try {
UploadService.Session s = uploads.get(id);
return Response.ok(new Dtos.UploadStatusResponse(s.id, s.received, s.size)).build();
} catch (IllegalArgumentException e) {
return Response.status(404).entity(error(e.getMessage())).build();
}
}
@POST
@jakarta.ws.rs.Path("/{id}/complete")
@Blocking
public Response complete(@PathParam("id") String id) {
try {
LasFile f = uploads.complete(id);
return Response.ok(Dtos.FileSummary.of(f)).build();
} catch (IllegalArgumentException e) {
return Response.status(404).entity(error(e.getMessage())).build();
} catch (IOException e) {
return Response.status(500).entity(error(e.getMessage())).build();
}
}
private static java.util.Map<String, String> error(String msg) {
return java.util.Map.of("error", msg == null ? "error" : msg);
}
}