diff --git a/app/src-tauri/src/data.rs b/app/src-tauri/src/data.rs index 17f4929..f081992 100644 --- a/app/src-tauri/src/data.rs +++ b/app/src-tauri/src/data.rs @@ -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( diff --git a/app/src/components/ColumnDrawing.tsx b/app/src/components/ColumnDrawing.tsx index 4b0a0fb..1acbcd7 100644 --- a/app/src/components/ColumnDrawing.tsx +++ b/app/src/components/ColumnDrawing.tsx @@ -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"; @@ -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 ( 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; @@ -111,7 +116,16 @@ export function ProposedColumn({ column, waterFt, selected, onSelect }: { )} ); - })} + }), + ...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 ? : null; + })), + ...suggestedSlotted.map((p, j) => ( + + )), + ]} ); } diff --git a/app/src/model/drilling.test.ts b/app/src/model/drilling.test.ts new file mode 100644 index 0000000..a402b8c --- /dev/null +++ b/app/src/model/drilling.test.ts @@ -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[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(); + }); +}); diff --git a/app/src/model/drilling.ts b/app/src/model/drilling.ts new file mode 100644 index 0000000..7d16f56 --- /dev/null +++ b/app/src/model/drilling.ts @@ -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, + }; +} diff --git a/app/src/text/index.ts b/app/src/text/index.ts index ff66397..a0553d0 100644 --- a/app/src/text/index.ts +++ b/app/src/text/index.ts @@ -56,6 +56,28 @@ export const text = { andMore: (k: number) => `and ${k} more`, howGood: "How good are these estimates?", + drilling: { + title: "Drilling suggestion", + note: "From past drilling nearby. The driller decides.", + doneNearby: "What was done nearby", + drilled: (low: number, middle: number, high: number) => (low === high ? `Drilled ${low} ft deep` : `Drilled ${low} to ${high} ft deep (middle ${middle} ft)`), + pump: (low: number, middle: number, high: number) => (low === high ? `Pump at ${low} ft` : `Pumps at ${low} to ${high} ft (middle ${middle} ft)`), + well: (id: string, distance: string, drilled: number | null) => `${id} · ${distance}${drilled != null ? ` · ${drilled} ft deep` : ""}`, + suggest: "What the records suggest", + water: (feet: number) => `Water level expected about ${feet} ft`, + slotted: (top: number, bottom: number, level: string) => `Slotted pipe ${top} to ${bottom} ft (${level.toLowerCase()})`, + drillTo: (feet: number, rough: boolean) => `How deep to drill: about ${feet} ft${rough ? " (rough guess)" : ""}`, + drillToNote: { + deepestNearby: "Never deeper than the deepest borewell nearby. This rule is still to be confirmed by DWO.", + noneKnown: "No borewell nearby records how deep it was drilled, so this depth is not checked against them. This rule is still to be confirmed by DWO.", + }, + noWater: "There is no recent water level within 1 km, so no slotted pipe or depth is suggested.", + noWaterBearing: "No water-bearing sand of 10 ft or more is proposed below the water level here, so no slotted pipe or depth is suggested.", + nearbyShallower: "The borewells nearby all stop above the water-bearing sand proposed here, so no slotted pipe or depth is suggested.", + keyNearby: "Slotted pipe in the nearest borewells, at the same height", + keySuggested: "Slotted pipe suggested", + }, + quality: { checking: "Checking…", notEnough: "There are not enough borewells with layers and a location to check yet (at least 10 are needed).", diff --git a/app/src/views/MapView.tsx b/app/src/views/MapView.tsx index 90acfbb..883bed6 100644 --- a/app/src/views/MapView.tsx +++ b/app/src/views/MapView.tsx @@ -4,6 +4,7 @@ import { NOTHING_PROPOSED, NothingProposedPattern, ProposedColumn, RecordedColum import { SiteMap, located } from "@/components/SiteMap"; import type { Records, SiteRecord } from "@/lib/api"; import type { Model } from "@/lib/useModel"; +import { nearbyWells, suggestDrilling, type Suggestion } from "@/model/drilling"; import { predictColumn } from "@/model/model"; import { GROUP_COLOUR, blocks, distanceInWords, sharesInWords, summary, waterHere, type Block } from "@/model/words"; import { text } from "@/text"; @@ -37,7 +38,7 @@ export function MapView({ records, model, onHowGood }: { records: Records; model
{site ? setChosen({ borewell: id })} /> // Keyed by the spot, so a layer selected at one spot is not carried to the next. - : spot ? + : spot ? :

{text.pickHint}

}
@@ -80,11 +81,12 @@ function Recorded({ site: { borewell: b, strata }, sites, onSelect }: { site: Si ); } -function Proposed({ spot, model, borewells, onHowGood }: { spot: [number, number]; model: Model | "failed" | null; borewells: Borewell[]; onHowGood: () => void }) { +function Proposed({ spot, model, sites, borewells, onHowGood }: { spot: [number, number]; model: Model | "failed" | null; sites: SiteRecord[]; borewells: Borewell[]; onHowGood: () => void }) { const ready = model && model !== "failed" ? model : null; // Worked out again when the records change, so a reload shows the new answer for the same spot. const column = useMemo(() => (ready ? predictColumn(spot[0], spot[1], ready.prepared, ready.grid) : null), [spot, ready]); const water = useMemo(() => waterHere(spot[0], spot[1], borewells), [spot, borewells]); + const nearby = useMemo(() => (ready ? nearbyWells(spot[0], spot[1], sites, ready.grid) : []), [spot, sites, ready]); const [picked, setPicked] = useState<{ top: number; bottom: number } | null>(null); const heading =

{text.proposedHere}

; @@ -92,27 +94,34 @@ function Proposed({ spot, model, borewells, onHowGood }: { spot: [number, number const waterLine =

{water ? text.waterExpected(Math.round(water.feet), distanceInWords(water.nearestKm), water.since) : text.noWater}

; if (model === "failed") return <>{heading}

{text.groundFailed}

; if (!ready) return <>{heading}

{text.readingGround}

; + // With no layers to propose, what was done nearby is still worth seeing. + const doneNearby = nearby.length > 0 && ; // Outside the ground-height data nothing can be placed by height, whatever borewells are near. - if (!column) return <>{heading}

{text.noGroundHere}

{waterLine}{howGood}; - if (!column.some((s) => s.likely)) return <>{heading}

{text.notEnoughHere}

{waterLine}{howGood}; + if (!column) return <>{heading}

{text.noGroundHere}

{waterLine}{doneNearby}{howGood}; + if (!column.some((s) => s.likely)) return <>{heading}

{text.notEnoughHere}

{waterLine}{doneNearby}{howGood}; // Only the borewells behind what is actually proposed, not those behind stretches left empty. const behind = new Map(); for (const step of column) if (step.likely) for (const s of step.sources) behind.set(s.id, s.km); // The selected stretch, if it still exists: after a reload the layers may have moved. const selected = picked ? blocks(column).find((b) => b.top === picked.top && b.bottom === picked.bottom) ?? null : null; + const suggestion = suggestDrilling(column, water, nearby); return ( <> {heading}

{text.fromBorewells(behind.size, distanceInWords(Math.min(...behind.values())))}

{waterLine} - setPicked({ top: b.top, bottom: b.bottom })} /> + setPicked({ top: b.top, bottom: b.bottom })} + nearbySlotted={nearby.map((n) => n.slotted)} suggestedSlotted={suggestion?.proposal?.slotted} />
  • {text.levelMeaning.likely}
  • {text.levelMeaning.rough}
  • {text.levelMeaning.none}
  • + {nearby.some((n) => n.slotted.length) &&
  • {text.drilling.keyNearby}
  • } + {suggestion?.proposal &&
  • {text.drilling.keySuggested}
  • }
{selected ? :

{text.clickLayer}

} + {suggestion && } {howGood} ); @@ -140,6 +149,40 @@ function BlockDetails({ block }: { block: Block }) { ); } +/** Both halves of the drilling suggestion, always together, so the suggestion can be weighed against what was done. */ +function Drilling({ s }: { s: Suggestion }) { + const t = text.drilling, ft = (n: number) => Math.round(n); + return ( +
+

{t.title}

+

{t.note}

+

{t.doneNearby}

+ {s.drilled &&

{t.drilled(ft(s.drilled.low), ft(s.drilled.middle), ft(s.drilled.high))}

} + {s.pump &&

{t.pump(ft(s.pump.low), ft(s.pump.middle), ft(s.pump.high))}

} +
    + {s.nearby.map((n) =>
  • {t.well(n.borewellId, distanceInWords(n.km), n.drilledFt != null ? ft(n.drilledFt) : null)}
  • )} +
+

{t.suggest}

+ {s.proposal ? ( + <> +

{t.water(ft(s.proposal.waterFt))}

+
    {s.proposal.slotted.map((p) =>
  • {t.slotted(ft(p.top), ft(p.bottom), text.levels[p.level])}
  • )}
+

{t.drillTo(ft(s.proposal.drillToFt), s.proposal.slotted[s.proposal.slotted.length - 1].level === "rough")}

+

{t.drillToNote[s.proposal.limit]}

+ + ) :

{t[s.noProposal ?? "noWaterBearing"]}

} +
+ ); +} + +function PipeSwatch({ suggested = false }: { suggested?: boolean }) { + return ( + + ); +} + /** A small sample of how a level is drawn, for the key under the column. */ function Swatch({ opacity, dash }: { opacity?: number; dash?: string }) { return ( diff --git a/docs/superpowers/plans/2026-10-14-stratavision-phase-4-drilling.md b/docs/superpowers/plans/2026-10-14-stratavision-phase-4-drilling.md new file mode 100644 index 0000000..8d33bc4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-stratavision-phase-4-drilling.md @@ -0,0 +1,70 @@ +# StrataVision Phase 4 (Drilling Suggestion) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Under the proposed column for a picked spot, a drilling suggestion in two halves shown together: what was done at the nearest sites, and what the records suggest (water level, where to put slotted pipe, how deep to drill). + +**Architecture:** Two pure functions in `app/src/model/drilling.ts`: `nearbyWells` gathers the nearest sites' depths, pumps and slotted pipe (moved into the picked spot's depths by height above sea level), and `suggestDrilling(column, water, nearby)` reads the model's column and does not change it. The Map panel shows both; the drawing shows nearby slotted pipe beside the column. + +**Tech Stack:** TypeScript, React, Vitest; no new dependencies. + +**Spec:** `docs/superpowers/specs/2026-10-13-stratavision-design.md`, "Screens → Map: drilling suggestion". + +## Global Constraints + +- Branch `phase-4-drilling` from `main`. One pull request; the owner merges. No AI attribution lines. +- Nearby: the 5 nearest **sites** within 3 km (`ROUGH.nearestKm`), borewells within `SAME_SITE_KM` of each other being one site, represented by its nearest borewell. +- Depths below ground are feet. A borewell's `totalDepth` is metres when its `depthUnit` is `"m"` and is converted; pipe depths and `pumpLowering` are always feet. +- Slotted pipe proposed: each run of steps whose likeliest group is water-bearing, at least 10 ft thick, entirely below the expected water level, and `likely` or `rough`; a run carries the weaker level of its steps. +- How deep to drill: the bottom of the lowest such run, never deeper than the deepest borewell among the nearby sites. This rule is an assumption to confirm with DWO, and the screen says the driller decides. +- No such run, or no expected water level: the screen says so and shows only what was done nearby. No nearby sites: no suggestion. +- All wording in `app/src/text/index.ts`. + +## Review Focus + +1. A nearby borewell on higher or lower ground: its slotted pipe must be drawn at the same height, not the same depth. (Task 1) +2. Several borewells at one site must count as one of the 5 sites. (Task 1) +3. Old records in metres: total depth converted, pipes and pump not. (Task 1) +4. A run of water-bearing sand that starts above the water level: only the part below can be proposed, and only if that part is at least 10 ft. (Task 1) +5. No pump or depth recorded anywhere nearby: those lines are left out, not shown as zero. (Task 2) + +--- + +### Task 1: The suggestion, as numbers + +**Files:** Create `app/src/model/drilling.ts`, `app/src/model/drilling.test.ts`. + +**Interfaces:** + +```ts +export interface Span { top: number; bottom: number } +export interface NearbyWell { id: string; borewellId: string; km: number; drilledFt: number | null; pumpFt: number | null; slotted: Span[] /* in the picked spot's depths */ } +export interface Spread { low: number; middle: number; high: number } +export interface Suggestion { + nearby: NearbyWell[]; + drilled: Spread | null; + pump: Spread | null; + /** What the records suggest; null with the reason when nothing can be proposed. */ + proposal: { waterFt: number; slotted: (Span & { level: "likely" | "rough" })[]; drillToFt: number } | null; + noProposal: "noWater" | "noWaterBearing" | null; +} +export function nearbyWells(latitude: number, longitude: number, sites: SiteWithPipes[], grid: ElevationGrid): NearbyWell[]; +export function suggestDrilling(column: readonly Step[], water: WaterHere | null, nearby: NearbyWell[]): Suggestion | null; +``` + +- [ ] Tests first (each fails before the code exists): nearest 5 sites within 3 km, a crowded site once; slotted pipe moved by height on sloping ground; metres total depth converted; spreads (low, middle, high) and null when nothing recorded; slotted proposed only below the water level and only in runs of 10 ft or more, carrying the weaker level; a run straddling the water level is cut at it; depth capped at the deepest nearby borewell; no water → `noWater`; no run → `noWaterBearing`; no nearby → null. +- [ ] Implement; `npm test`, typecheck, lint. Commit `Work out a drilling suggestion`. + +### Task 2: On the Map panel + +**Files:** Modify `app/src/views/MapView.tsx`, `app/src/components/ColumnDrawing.tsx`, `app/src/text/index.ts`, `app/src-tauri/src/data.rs` (made-up data: slotted pipe and pump), `e2e/smoke.mjs`. + +- [ ] Under the column: heading "Drilling suggestion" and "From past drilling nearby. The driller decides."; **What was done nearby**: drilled depth spread, pump depth spread, each nearby borewell with its distance; **What the records suggest**: water level, slotted pipe stretches with their level, how deep to drill with "(at most as deep as the deepest borewell nearby)", or the reason there is no proposal. Lines with nothing recorded are left out. +- [ ] The drawing shows each nearby borewell's slotted pipe as a thin blue line beside the column, at its height, and the proposed slotted stretches as dashed blue brackets; the key explains both. +- [ ] Made-up data: every located borewell gets slotted pipe 60–120 ft and a pump at 100–130 ft. The real-app test expects "Drilling suggestion" and "How deep to drill". +- [ ] Verify in the real app with a screenshot. Commit `Show the drilling suggestion`. + +### Task 3: Review and pull request + +- [ ] Fresh reviewer on the branch; fix Critical/Important with a failing test first. +- [ ] Pull request with screenshots described; Auto-fix on. diff --git a/e2e/smoke.mjs b/e2e/smoke.mjs index 173cd9f..244c2c0 100644 --- a/e2e/smoke.mjs +++ b/e2e/smoke.mjs @@ -106,6 +106,8 @@ const scenarios = { await expectScreen("Water-bearing sand"); await expectScreen("Water level expected about"); await expectScreen("levels measured since 2023"); + await expectScreen("Drilling suggestion"); + await expectScreen("How deep to drill: about"); // The middle pin is two borewells on top of each other; the panel offers both. await click(x - 60, y - 45); await expectScreen("2 borewells are at this spot:");