Initial commit: LAS Stream Viewer (Quarkus backend + React log-plot UI)
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user