Initial commit: LAS Stream Viewer (Quarkus backend + React log-plot UI)
This commit is contained in:
70
src/main/java/com/oiusa/las/index/LineIndex.java
Normal file
70
src/main/java/com/oiusa/las/index/LineIndex.java
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/main/java/com/oiusa/las/index/LineReader.java
Normal file
67
src/main/java/com/oiusa/las/index/LineReader.java
Normal 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);
|
||||
}
|
||||
}
|
||||
188
src/main/java/com/oiusa/las/index/Pyramid.java
Normal file
188
src/main/java/com/oiusa/las/index/Pyramid.java
Normal 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;
|
||||
}
|
||||
}
|
||||
103
src/main/java/com/oiusa/las/index/RowParser.java
Normal file
103
src/main/java/com/oiusa/las/index/RowParser.java
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/main/java/com/oiusa/las/model/Curve.java
Normal file
14
src/main/java/com/oiusa/las/model/Curve.java
Normal 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) {
|
||||
}
|
||||
13
src/main/java/com/oiusa/las/model/HeaderSection.java
Normal file
13
src/main/java/com/oiusa/las/model/HeaderSection.java
Normal 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) {
|
||||
}
|
||||
74
src/main/java/com/oiusa/las/model/LasFile.java
Normal file
74
src/main/java/com/oiusa/las/model/LasFile.java
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/main/java/com/oiusa/las/model/ResolvedRole.java
Normal file
16
src/main/java/com/oiusa/las/model/ResolvedRole.java
Normal 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) {
|
||||
}
|
||||
153
src/main/java/com/oiusa/las/service/ChannelRoles.java
Normal file
153
src/main/java/com/oiusa/las/service/ChannelRoles.java
Normal 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;
|
||||
}
|
||||
}
|
||||
283
src/main/java/com/oiusa/las/service/CurveDataService.java
Normal file
283
src/main/java/com/oiusa/las/service/CurveDataService.java
Normal 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 (1st–99th 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; }
|
||||
}
|
||||
}
|
||||
128
src/main/java/com/oiusa/las/service/FileStore.java
Normal file
128
src/main/java/com/oiusa/las/service/FileStore.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
231
src/main/java/com/oiusa/las/service/IndexService.java
Normal file
231
src/main/java/com/oiusa/las/service/IndexService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
94
src/main/java/com/oiusa/las/service/LasHeaderParser.java
Normal file
94
src/main/java/com/oiusa/las/service/LasHeaderParser.java
Normal 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);
|
||||
}
|
||||
}
|
||||
111
src/main/java/com/oiusa/las/service/UploadService.java
Normal file
111
src/main/java/com/oiusa/las/service/UploadService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
156
src/main/java/com/oiusa/las/web/CurveResource.java
Normal file
156
src/main/java/com/oiusa/las/web/CurveResource.java
Normal 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);
|
||||
}
|
||||
}
|
||||
78
src/main/java/com/oiusa/las/web/Dtos.java
Normal file
78
src/main/java/com/oiusa/las/web/Dtos.java
Normal 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) {}
|
||||
}
|
||||
139
src/main/java/com/oiusa/las/web/FileResource.java
Normal file
139
src/main/java/com/oiusa/las/web/FileResource.java
Normal 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);
|
||||
}
|
||||
}
|
||||
51
src/main/java/com/oiusa/las/web/LinesResource.java
Normal file
51
src/main/java/com/oiusa/las/web/LinesResource.java
Normal 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);
|
||||
}
|
||||
}
|
||||
109
src/main/java/com/oiusa/las/web/SearchResource.java
Normal file
109
src/main/java/com/oiusa/las/web/SearchResource.java
Normal 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/main/java/com/oiusa/las/web/StreamResource.java
Normal file
124
src/main/java/com/oiusa/las/web/StreamResource.java
Normal 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/main/java/com/oiusa/las/web/UploadResource.java
Normal file
99
src/main/java/com/oiusa/las/web/UploadResource.java
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user