Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion app/src-tauri/src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,34 @@ mod tests {
latitude: located.then_some(26.83 + 0.01 * (at / 5) as f64),
longitude: located.then_some(80.93 + 0.01 * (at % 5) as f64),
total_depth: Some(200.0),
pump_lowering: located.then_some(100.0 + 5.0 * (i % 7) as f64),
..Default::default()
},
if located { &strata } else { &[] },
&[],
&if located {
vec![
PipeSegment {
start_depth: 0.0,
end_depth: 60.0,
pipe_type: "plain".into(),
..Default::default()
},
PipeSegment {
start_depth: 60.0,
end_depth: 120.0,
pipe_type: "slotted".into(),
..Default::default()
},
PipeSegment {
start_depth: 120.0,
end_depth: 200.0,
pipe_type: "plain".into(),
..Default::default()
},
]
} else {
vec![]
},
)?;
if located {
repo::layers::add_water_reading(
Expand Down
20 changes: 17 additions & 3 deletions app/src/components/ColumnDrawing.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { KeyboardEvent, ReactNode } from "react";
import type { StrataLayer } from "@strata/core";
import type { Step } from "@/model/model";
import type { Span } from "@/model/drilling";
import { GROUP_COLOUR, GROUP_NAME, blocks, type Block } from "@/model/words";
import { text } from "@/text";

Expand Down Expand Up @@ -76,14 +77,18 @@ export function NothingProposedPattern() {
* is only a rough guess; and left empty, hatched, where there is not enough data to propose anything.
* Its water level, being worked out too, is a dashed line.
*/
export function ProposedColumn({ column, waterFt, selected, onSelect }: {
export function ProposedColumn({ column, waterFt, selected, onSelect, nearbySlotted = [], suggestedSlotted = [] }: {
column: readonly Step[]; waterFt?: number | null; selected: Block | null; onSelect: (block: Block) => void;
/** Each nearby borewell's slotted pipe, at the same height: thin lines inside the column's right edge. */
nearbySlotted?: Span[][];
/** Where slotted pipe is suggested: dashed lines inside the column's left edge. */
suggestedSlotted?: Span[];
}) {
const all = blocks(column);
const depth = all[all.length - 1]?.bottom ?? 1;
return (
<Frame depth={depth} marks={[0, ...all.map((b) => b.bottom)]} water={waterFt != null ? { feet: waterFt, expected: true } : null} label={text.proposedHere} clickable>
{(y, labelY) => all.map((b) => {
{(y, labelY) => [...all.map((b) => {
const name = b.group ? GROUP_NAME[b.group] : text.levels.none;
const at = labelY(b.top, b.bottom);
const isSelected = selected?.top === b.top && selected.bottom === b.bottom;
Expand Down Expand Up @@ -111,7 +116,16 @@ export function ProposedColumn({ column, waterFt, selected, onSelect }: {
)}
</g>
);
})}
}),
...nearbySlotted.flatMap((spans, i) => spans.map((p, j) => {
const [top, bottom] = [Math.max(0, p.top), Math.min(depth, p.bottom)];
const x = COL_X + COL_W - 4 - 4 * i;
return bottom > top ? <line key={`n${i}-${j}`} x1={x} x2={x} y1={y(top)} y2={y(bottom)} stroke={WATER} strokeWidth="2" pointerEvents="none" /> : null;
})),
...suggestedSlotted.map((p, j) => (
<line key={`s${j}`} x1={COL_X + 5} x2={COL_X + 5} y1={y(p.top)} y2={y(Math.min(depth, p.bottom))} stroke={WATER} strokeWidth="4" strokeDasharray="5 3" pointerEvents="none" />
)),
]}
</Frame>
);
}
Expand Down
143 changes: 143 additions & 0 deletions app/src/model/drilling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import type { PipeSegment } from "@strata/core";
import { nearbyWells, suggestDrilling, type NearbyWell, type SiteWithPipes } from "./drilling";
import type { Group } from "./groups";
import type { Step } from "./model";
import { CENTRE, east, flatGrid, north, site, slopeGrid } from "./testGround";

const flat = flatGrid(120);
const pipe = (startDepth: number, endDepth: number, pipeType: "plain" | "slotted" = "slotted") => ({ startDepth, endDepth, pipeType }) as PipeSegment;
const well = (id: string, lat: number, lon: number, over: Parameters<typeof site>[4] = {}, pipes: PipeSegment[] = []): SiteWithPipes =>
({ ...site(id, lat, lon, [[0, 100, "clay"]], over), pipes });
const near = (sites: SiteWithPipes[], grid = flat) => nearbyWells(CENTRE[0], CENTRE[1], sites, grid);

describe("nearbyWells", () => {
it("takes the 5 nearest sites within 3 km, a crowded site once", () => {
const crowd = Array.from({ length: 6 }, (_, i) => well(`c${i}`, north(i * 0.005), east(0.5)));
const others = [1, 1.5, 2, 2.5, 2.8].map((km, i) => well(`o${i}`, north(0), east(-km)));
const got = near([...crowd, ...others, well("far", north(3.5), east(0))]);
expect(got.map((w) => w.id)).toEqual(["c0", "o0", "o1", "o2", "o3"]);
expect(got[1].km).toBeCloseTo(1, 2);
});

it("moves a nearby borewell's slotted pipe to the same height at the picked spot", () => {
// The ground climbs eastwards; a borewell 2 km east stands 15.85 ft higher than the spot.
const got = near([well("east", north(0), east(2), {}, [pipe(0, 100, "plain"), pipe(100, 120)])], slopeGrid(60, 180));
expect(got[0].slotted).toHaveLength(1);
expect(got[0].slotted[0].top).toBeCloseTo(84.15, 1);
expect(got[0].slotted[0].bottom).toBeCloseTo(104.15, 1);
});

it("converts the depth of an old record kept in metres, and nothing else", () => {
const [w] = near([well("old", north(0), east(0.5), { depthUnit: "m", totalDepth: 100, pumpLowering: 120 }, [pipe(80, 100)])]);
expect(w.drilledFt).toBeCloseTo(328.08, 1);
expect(w.pumpFt).toBe(120);
expect(w.slotted[0]).toMatchObject({ top: 80, bottom: 100 });
});

it("does not draw the pipe of a borewell whose ground height is unknown", () => {
// Outside the ground-height data and with no height of its own: its pipe cannot be put at the same height.
const [w] = nearbyWells(26.6005, 80.95, [well("edge", 26.599, 80.95, { totalDepth: 200 }, [pipe(100, 120)])], flat);
expect(w).toMatchObject({ drilledFt: 200, slotted: [] });
});

it("leaves out what was not recorded", () => {
const [w] = near([well("bare", north(0), east(0.5), { totalDepth: null, pumpLowering: null })]);
expect(w).toMatchObject({ drilledFt: null, pumpFt: null, slotted: [] });
});

it("finds nobody beyond 3 km", () => {
expect(near([well("far", north(4), east(0))])).toEqual([]);
});
});

const step = (top: number, likely: Group | null, confidence: Step["confidence"] = likely ? "likely" : "none"): Step =>
({ top, bottom: top + 5, shares: null, likely, confidence, sources: [] });
/** Steps from `top` to `bottom` in 5 ft. */
const run = (top: number, bottom: number, likely: Group | null, confidence?: Step["confidence"]) =>
Array.from({ length: (bottom - top) / 5 }, (_, i) => step(top + 5 * i, likely, confidence));
const water = (feet: number) => ({ feet, nearestKm: 0.4, since: "2023" });
const nb = (drilledFt: number | null, pumpFt: number | null = null): NearbyWell => ({ id: `n${drilledFt}`, borewellId: "N", km: 0.5, drilledFt, pumpFt, slotted: [] });

const COLUMN = [
...run(0, 40, "clay"), ...run(40, 80, "waterBearing"), ...run(80, 90, "clay"), ...run(90, 95, "waterBearing", "rough"),
...run(95, 120, "clay"), ...run(120, 150, "waterBearing", "rough"), ...run(150, 160, "waterBearing"), ...run(160, 200, "clay"),
];

describe("suggestDrilling", () => {
it("proposes slotted pipe in water-bearing stretches of 10 ft or more below the water level", () => {
const s = suggestDrilling(COLUMN, water(50), [nb(200)])!;
expect(s.proposal!.slotted).toEqual([
{ top: 50, bottom: 80, level: "likely" }, // cut at the water level
{ top: 120, bottom: 160, level: "rough" }, // the weaker level of its steps
]);
expect(s.proposal!.waterFt).toBe(50);
expect(s.noProposal).toBeNull();
});

it("judges a stretch only by the part below the water level", () => {
// Rough above the water, likely below it: the slotted pipe goes only in the likely part.
const s = suggestDrilling([...run(0, 40, "clay"), ...run(40, 55, "waterBearing", "rough"), ...run(55, 110, "waterBearing"), ...run(110, 150, "clay")], water(59), [nb(200)])!;
expect(s.proposal!.slotted).toEqual([{ top: 59, bottom: 110, level: "likely" }]);
});

it("drills to the bottom of the lowest stretch", () => {
expect(suggestDrilling(COLUMN, water(50), [nb(200)])!.proposal!.drillToFt).toBe(160);
});

it("never deeper than the deepest borewell nearby", () => {
expect(suggestDrilling(COLUMN, water(50), [nb(140), nb(120)])!.proposal!.drillToFt).toBe(140);
});

it("leaves out a stretch that is less than 10 ft once the part above the water is cut off", () => {
const s = suggestDrilling([...run(0, 40, "clay"), ...run(40, 55, "waterBearing"), ...run(55, 100, "clay")], water(50), [nb(100)])!;
expect(s.proposal).toBeNull();
expect(s.noProposal).toBe("noWaterBearing");
});

it("proposes nothing where the model would not show anything", () => {
const s = suggestDrilling([...run(0, 50, "clay"), ...run(50, 100, null)], water(20), [nb(100)])!;
expect(s.noProposal).toBe("noWaterBearing");
});

it("says so, and shows only what was done nearby, without a water level", () => {
const s = suggestDrilling(COLUMN, null, [nb(200, 110)])!;
expect(s.proposal).toBeNull();
expect(s.noProposal).toBe("noWater");
expect(s.nearby).toHaveLength(1);
});

it("gives the spread of depths and pump positions nearby, leaving out what nobody recorded", () => {
const s = suggestDrilling(COLUMN, water(50), [nb(200, 100), nb(300, null), nb(250, 120)])!;
expect(s.drilled).toEqual({ low: 200, middle: 250, high: 300 });
expect(s.pump).toEqual({ low: 100, middle: 110, high: 120 });
expect(suggestDrilling(COLUMN, water(50), [nb(null, null)])!).toMatchObject({ drilled: null, pump: null });
});

it("never suggests slotted pipe below the depth it suggests drilling to", () => {
// Nearby borewells went to 110 ft: the stretch at 120–160 cannot be reached.
const s = suggestDrilling(COLUMN, water(50), [nb(110)])!;
expect(s.proposal).toEqual({ waterFt: 50, slotted: [{ top: 50, bottom: 80, level: "likely" }], drillToFt: 80, limit: "deepestNearby" });
});

it("cuts a stretch where the depth limit falls inside it, and drops it under 10 ft", () => {
expect(suggestDrilling(COLUMN, water(50), [nb(145)])!.proposal!.slotted).toEqual([
{ top: 50, bottom: 80, level: "likely" }, { top: 120, bottom: 145, level: "rough" },
]);
expect(suggestDrilling(COLUMN, water(50), [nb(125)])!.proposal!.slotted).toEqual([{ top: 50, bottom: 80, level: "likely" }]);
});

it("proposes nothing when every borewell nearby is shallower than the water-bearing sand", () => {
const s = suggestDrilling(COLUMN, water(50), [nb(40), nb(35)])!;
expect(s.proposal).toBeNull();
expect(s.noProposal).toBe("nearbyShallower");
});

it("says when no borewell nearby records its depth, instead of claiming a limit", () => {
expect(suggestDrilling(COLUMN, water(50), [nb(null)])!.proposal).toMatchObject({ drillToFt: 160, limit: "noneKnown" });
});

it("has no suggestion with nobody nearby", () => {
expect(suggestDrilling(COLUMN, water(50), [])).toBeNull();
});
});
160 changes: 160 additions & 0 deletions app/src/model/drilling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* A drilling suggestion for a picked spot, in two halves always shown together: what was done at
* the nearest sites, and what the records suggest. It reads the model's column and never changes it.
* The driller decides; the rule for how deep to drill is an assumption to confirm with DWO.
*/
import { METRES_TO_FEET, heightAt, type ElevationGrid, type PipeSegment } from "@strata/core";
import { ROUGH, SAME_SITE_KM } from "./constants";
import type { ModelSite, Step } from "./model";
import type { WaterHere } from "./words";

export type SiteWithPipes = ModelSite & { pipes: PipeSegment[] };

/** Feet below ground. */
export interface Span {
top: number;
bottom: number;
}

export interface NearbyWell {
id: string;
borewellId: string;
km: number;
/** How deep it was drilled, feet; null when not recorded. */
drilledFt: number | null;
/** How deep its pump hangs, feet; null when not recorded. */
pumpFt: number | null;
/** Its slotted pipe, moved to the picked spot's depths by height above sea level. */
slotted: Span[];
}

/** The lowest, middle and highest of some values. */
export interface Spread {
low: number;
middle: number;
high: number;
}

export interface Suggestion {
nearby: NearbyWell[];
drilled: Spread | null;
pump: Spread | null;
proposal: {
waterFt: number;
slotted: (Span & { level: "likely" | "rough" })[];
drillToFt: number;
/** Whether the depth was held to the deepest borewell nearby, or none nearby records its depth. */
limit: "deepestNearby" | "noneKnown";
} | null;
/**
* Why nothing is proposed: no expected water level; no water-bearing stretch to put slotted pipe
* in; or every borewell nearby stopped above it.
*/
noProposal: "noWater" | "noWaterBearing" | "nearbyShallower" | null;
}

/** How many sites count as "nearby", and the thinnest water-bearing stretch worth slotted pipe. */
const NEARBY_SITES = 5;
const MIN_SLOTTED_FT = 10;

const groundFt = (grid: ElevationGrid, b: ModelSite["borewell"]) => {
const m = b.groundElevationM ?? heightAt(grid, b.latitude!, b.longitude!);
return m == null ? null : m * METRES_TO_FEET;
};

/** The nearest sites within reach of a spot, each represented by its nearest borewell. */
export function nearbyWells(latitude: number, longitude: number, sites: SiteWithPipes[], grid: ElevationGrid): NearbyWell[] {
const kx = 111.32 * Math.cos((latitude * Math.PI) / 180);
const here = heightAt(grid, latitude, longitude);
const candidates = sites
.filter((s) => s.borewell.latitude != null && s.borewell.longitude != null)
.map((s) => {
const x = (s.borewell.longitude! - longitude) * kx, y = (s.borewell.latitude! - latitude) * 110.57;
return { s, x, y, km: Math.hypot(x, y) };
})
.filter((c) => c.km <= ROUGH.nearestKm)
.sort((a, b) => a.km - b.km);

const chosen: typeof candidates = [];
for (const c of candidates) {
if (chosen.length === NEARBY_SITES) break;
// A crowded site counts once, by its nearest borewell.
if (!chosen.some((o) => Math.hypot(o.x - c.x, o.y - c.y) <= SAME_SITE_KM)) chosen.push(c);
}

return chosen.map(({ s: { borewell: b, pipes }, km }) => {
const own = groundFt(grid, b);
// Its depths become the spot's depths at the same height. Where either ground height is unknown
// that cannot be done, so its pipe is not drawn at all rather than drawn at the wrong height.
const shift = here != null && own != null ? here * METRES_TO_FEET - own : null;
return {
id: b.id, borewellId: b.borewellId, km,
// Only the total depth follows an old record's unit; pipes and the pump are always feet.
drilledFt: b.totalDepth != null ? b.totalDepth * (b.depthUnit === "m" ? METRES_TO_FEET : 1) : null,
pumpFt: b.pumpLowering ?? null,
slotted: shift == null ? [] : pipes
.filter((p) => p.pipeType === "slotted" && p.endDepth > p.startDepth)
.map((p) => ({ top: p.startDepth + shift, bottom: p.endDepth + shift }))
.filter((p) => p.bottom > 0)
.sort((a, b) => a.top - b.top),
};
});
}

function middleOf(sorted: number[]) {
const m = sorted.length >> 1;
return sorted.length % 2 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
}

const spread = (values: (number | null)[]): Spread | null => {
const v = values.filter((x): x is number => x != null).sort((a, b) => a - b);
return v.length ? { low: v[0], middle: middleOf(v), high: v[v.length - 1] } : null;
};

/**
* Where the records suggest slotted pipe and how deep to drill, beside what was done nearby. Slotted
* pipe goes in each water-bearing stretch the model shows (likely or rough) that is at least 10 ft
* thick below the expected water level. Drill to the bottom of the lowest one, but never deeper than
* the deepest borewell nearby. Null when there is nobody nearby to go by.
*/
export function suggestDrilling(column: readonly Step[], water: WaterHere | null, nearby: NearbyWell[]): Suggestion | null {
if (nearby.length === 0) return null;
const base = { nearby, drilled: spread(nearby.map((n) => n.drilledFt)), pump: spread(nearby.map((n) => n.pumpFt)) };
if (!water) return { ...base, proposal: null, noProposal: "noWater" };

const slotted: (Span & { level: "likely" | "rough" })[] = [];
let open: { top: number; bottom: number; rough: boolean } | null = null;
const close = () => {
if (!open) return;
const top = Math.max(open.top, water.feet);
if (open.bottom - top >= MIN_SLOTTED_FT) slotted.push({ top, bottom: open.bottom, level: open.rough ? "rough" : "likely" });
open = null;
};
for (const step of column) {
const wet = step.likely === "waterBearing" && step.confidence !== "none";
// Only the part below the water level gets slotted pipe, so only it says how sure the stretch is.
const rough = step.confidence === "rough" && step.bottom > water.feet;
if (!wet || (open && open.bottom !== step.top)) close();
if (!wet) continue;
if (open) {
open.bottom = step.bottom;
open.rough ||= rough;
} else {
open = { top: step.top, bottom: step.bottom, rough };
}
}
close();

if (slotted.length === 0) return { ...base, proposal: null, noProposal: "noWaterBearing" };
// Never deeper than the deepest borewell nearby, and so never slotted pipe below that either.
const deepest = base.drilled?.high ?? null;
const reachable = slotted
.map((p) => ({ ...p, bottom: deepest == null ? p.bottom : Math.min(p.bottom, deepest) }))
.filter((p) => p.bottom - p.top >= MIN_SLOTTED_FT);
if (reachable.length === 0) return { ...base, proposal: null, noProposal: "nearbyShallower" };
return {
...base,
proposal: { waterFt: water.feet, slotted: reachable, drillToFt: reachable[reachable.length - 1].bottom, limit: deepest == null ? "noneKnown" : "deepestNearby" },
noProposal: null,
};
}
Loading
Loading