# Recipes

> Worked examples that do something useful, rather than one call at a time.

The endpoint reference tells you what each call returns. This page shows what
to do with them.

## Summarise the ground across a project

Every factual report needs the table that says which strata were found, in how
many holes, and over what depths. It is usually built by opening every log and
tallying by hand.

<Steps>

<Step title="Find the project">

```bash
curl -H "Authorization: Bearer $KEY" \
  "https://api.gimlabs.io/v1/projects?limit=200"
```

Take the `id` of the one you want. Your own reference, like `GL-2201`, is in
`projectId`.

</Step>

<Step title="Check it has geology">

```bash
curl -H "Authorization: Bearer $KEY" \
  "https://api.gimlabs.io/v1/projects/6512a4b8c9d0e1f234567801"
```

The `tables` array tells you what the project holds, so you can stop early if
there is no `GEOL`:

```json
"tables": [
  { "code": "LOCA", "rows": 18, "name": "Location Details" },
  { "code": "GEOL", "rows": 132, "name": "Field Geological Descriptions" }
]
```

</Step>

<Step title="Pull the geology and group it">

```bash
curl -H "Authorization: Bearer $KEY" \
  "https://api.gimlabs.io/v1/projects/6512a4b8c9d0e1f234567801/tables/GEOL?limit=200"
```

Every row carries the borehole it came from, so no second lookup is needed:

```json
{
  "id": "6512a4b8c9d0e1f2345678ab",
  "table": "GEOL",
  "locationId": "BH01",
  "data": { "GEOL_TOP": "0.00", "GEOL_BASE": "0.35",
            "GEOL_GEOL": "MADE GROUND", "GEOL_DESC": "..." }
}
```

</Step>

</Steps>

### The whole thing

No SDK and no dependencies in any of these. Each uses whatever HTTP and JSON
its standard library already ships.

<CodeTabs>

<CodeTab label="Python">

```python
import json, os, statistics, urllib.parse, urllib.request

BASE = "https://api.gimlabs.io/v1"
KEY = os.environ["GIMLABS_API_KEY"]

def get(path, **params):
    url = BASE + path + ("?" + urllib.parse.urlencode(params) if params else "")
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {KEY}"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)

def paged(path, **params):
    """Follow meta.nextCursor until it comes back null."""
    cursor = None
    while True:
        body = get(path, limit=200, cursor=cursor, **params)
        yield from body["data"]
        cursor = body["meta"].get("nextCursor")
        if not cursor:
            return

def ground_model(project_id):
    strata, unnamed = {}, 0

    for row in paged(f"/projects/{project_id}/tables/GEOL"):
        d = row["data"]
        try:
            top, base = float(d["GEOL_TOP"]), float(d["GEOL_BASE"])
        except (KeyError, ValueError):
            continue
        if base <= top:
            continue

        # Correlate on the named formation only.
        name = (d.get("GEOL_GEOL") or "").strip()
        if not name:
            unnamed += 1
            continue

        s = strata.setdefault(name, {"holes": set(), "tops": [], "thick": []})
        s["holes"].add(row["locationId"])
        s["tops"].append(top)
        s["thick"].append(base - top)

    # Down the page in the order you would meet them.
    for name, s in sorted(strata.items(), key=lambda kv: statistics.median(kv[1]["tops"])):
        print(f"{name:<30}{len(s['holes']):>4} holes"
              f"  top {min(s['tops']):>6.2f}-{max(s['tops']):<6.2f}"
              f"  thickness {min(s['thick']):>5.2f}-{max(s['thick']):<5.2f}")

    if unnamed:
        print(f"{unnamed} beds had no stratum named and are not included.")
```

</CodeTab>

<CodeTab label="JavaScript">

```javascript
const BASE = "https://api.gimlabs.io/v1";
const KEY = process.env.GIMLABS_API_KEY;

async function get(path, params = {}) {
  const qs = new URLSearchParams(
    Object.entries(params).filter(([, v]) => v != null)
  ).toString();
  const res = await fetch(`${BASE}${path}${qs ? `?${qs}` : ""}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error.type}: ${body.error.message}`);
  return body;
}

/** Follow meta.nextCursor until it comes back null. */
async function* paged(path, params = {}) {
  let cursor;
  for (;;) {
    const body = await get(path, { ...params, limit: 200, cursor });
    yield* body.data;
    cursor = body.meta?.nextCursor;
    if (!cursor) return;
  }
}

const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];

async function groundModel(projectId) {
  const strata = new Map();
  let unnamed = 0;

  for await (const row of paged(`/projects/${projectId}/tables/GEOL`)) {
    const d = row.data;
    const top = Number(d.GEOL_TOP), base = Number(d.GEOL_BASE);
    if (!Number.isFinite(top) || !Number.isFinite(base) || base <= top) continue;

    // Correlate on the named formation only.
    const name = (d.GEOL_GEOL ?? "").trim();
    if (!name) { unnamed++; continue; }

    if (!strata.has(name)) strata.set(name, { holes: new Set(), tops: [], thick: [] });
    const s = strata.get(name);
    s.holes.add(row.locationId);
    s.tops.push(top);
    s.thick.push(base - top);
  }

  // Down the page in the order you would meet them.
  const ordered = [...strata].sort((a, b) => median(a[1].tops) - median(b[1].tops));
  for (const [name, s] of ordered) {
    console.log(
      `${name.padEnd(30)}${String(s.holes.size).padStart(4)} holes` +
      `  top ${Math.min(...s.tops).toFixed(2)}-${Math.max(...s.tops).toFixed(2)}` +
      `  thickness ${Math.min(...s.thick).toFixed(2)}-${Math.max(...s.thick).toFixed(2)}`
    );
  }

  if (unnamed) console.log(`${unnamed} beds had no stratum named and are not included.`);
}
```

</CodeTab>

<CodeTab label="C#">

```csharp
using System.Net.Http.Headers;
using System.Text.Json;

var baseUrl = "https://api.gimlabs.io/v1";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GIMLABS_API_KEY"));

async Task<JsonElement> Get(string path)
{
    var res = await http.GetAsync(baseUrl + path);
    var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
    if (!res.IsSuccessStatusCode)
    {
        var e = doc.RootElement.GetProperty("error");
        throw new Exception($"{Str(e, "type")}: {Str(e, "message")}");
    }
    return doc.RootElement.Clone();
}

// Follow meta.nextCursor until it comes back null.
async IAsyncEnumerable<JsonElement> Paged(string path)
{
    string? cursor = null;
    while (true)
    {
        var url = $"{path}?limit=200" + (cursor is null ? "" : $"&cursor={cursor}");
        var body = await Get(url);
        foreach (var row in body.GetProperty("data").EnumerateArray()) yield return row;

        cursor = body.GetProperty("meta").TryGetProperty("nextCursor", out var c)
                 && c.ValueKind == JsonValueKind.String ? c.GetString() : null;
        if (cursor is null) yield break;
    }
}

var strata = new Dictionary<string, Stratum>();
var unnamed = 0;

await foreach (var row in Paged($"/projects/{projectId}/tables/GEOL"))
{
    var d = row.GetProperty("data");
    if (!double.TryParse(Str(d, "GEOL_TOP"), out var top)) continue;
    if (!double.TryParse(Str(d, "GEOL_BASE"), out var b) || b <= top) continue;

    // Correlate on the named formation only.
    var name = Str(d, "GEOL_GEOL").Trim();
    if (name.Length == 0) { unnamed++; continue; }

    if (!strata.TryGetValue(name, out var s)) strata[name] = s = new Stratum();
    s.Holes.Add(Str(row, "locationId"));
    s.Tops.Add(top);
    s.Thick.Add(b - top);
}

// Down the page in the order you would meet them.
foreach (var (name, s) in strata.OrderBy(kv => Median(kv.Value.Tops)))
    Console.WriteLine($"{name,-30}{s.Holes.Count,4} holes" +
                      $"  top {s.Tops.Min(),6:F2}-{s.Tops.Max(),-6:F2}" +
                      $"  thickness {s.Thick.Min(),5:F2}-{s.Thick.Max(),-5:F2}");

if (unnamed > 0)
    Console.WriteLine($"{unnamed} beds had no stratum named and are not included.");

static string Str(JsonElement e, string p) =>
    e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";

static double Median(List<double> xs) => xs.Order().ElementAt(xs.Count / 2);

sealed class Stratum
{
    public HashSet<string> Holes { get; } = new();
    public List<double> Tops { get; } = new();
    public List<double> Thick { get; } = new();
}
```

</CodeTab>

<CodeTab label="PowerShell">

```powershell
$BaseUrl = "https://api.gimlabs.io/v1"
$Headers = @{ Authorization = "Bearer $($env:GIMLABS_API_KEY)" }

function Get-AllPages {
    # Follow meta.nextCursor until it comes back null.
    param([string]$Path)

    $cursor = $null
    do {
        $url = "$BaseUrl$Path" + "?limit=200"
        if ($cursor) { $url += "&cursor=$cursor" }
        $body = Invoke-RestMethod -Uri $url -Headers $Headers
        foreach ($row in $body.data) { $row }
        $cursor = $body.meta.nextCursor
    } while ($cursor)
}

$strata = @{}
$unnamed = 0

foreach ($row in Get-AllPages -Path "/projects/$ProjectId/tables/GEOL") {
    $top = 0.0; $base = 0.0
    if (-not [double]::TryParse($row.data.GEOL_TOP, [ref]$top)) { continue }
    if (-not [double]::TryParse($row.data.GEOL_BASE, [ref]$base)) { continue }
    if ($base -le $top) { continue }

    # Correlate on the named formation only.
    $name = "$($row.data.GEOL_GEOL)".Trim()
    if (-not $name) { $unnamed++; continue }

    if (-not $strata.ContainsKey($name)) {
        $strata[$name] = [pscustomobject]@{
            Holes = New-Object System.Collections.Generic.HashSet[string]
            Tops  = New-Object System.Collections.Generic.List[double]
            Thick = New-Object System.Collections.Generic.List[double]
        }
    }
    [void]$strata[$name].Holes.Add([string]$row.locationId)
    $strata[$name].Tops.Add($top)
    $strata[$name].Thick.Add($base - $top)
}

# Down the page in the order you would meet them.
$ordered = $strata.GetEnumerator() | Sort-Object {
    $t = @($_.Value.Tops | Sort-Object); $t[[int]($t.Count / 2)]
}
foreach ($e in $ordered) {
    $s = $e.Value
    "{0,-30}{1,4} holes  top {2,6:F2}-{3,-6:F2}  thickness {4,5:F2}-{5,-5:F2}" -f `
        $e.Key, $s.Holes.Count,
        ($s.Tops | Measure-Object -Minimum).Minimum, ($s.Tops | Measure-Object -Maximum).Maximum,
        ($s.Thick | Measure-Object -Minimum).Minimum, ($s.Thick | Measure-Object -Maximum).Maximum
}

if ($unnamed -gt 0) { "$unnamed beds had no stratum named and are not included." }
```

</CodeTab>

</CodeTabs>

### What it gives you

```
MADE GROUND                     18 holes  top   0.00-0.30    thickness  0.60-3.20
ALLUVIUM                         6 holes  top   0.90-2.80    thickness  0.80-2.40
RIVER TERRACE DEPOSITS          16 holes  top   1.40-4.10    thickness  2.20-4.60
LONDON CLAY FORMATION           18 holes  top   4.00-8.60    thickness  4.10-12.60

23 beds had no stratum named and are not included.
```

Which is the paragraph, already written for you: Made Ground was encountered in
all 18 holes to between 0.60 m and 3.20 m; Alluvium was proved in 6 holes only,
so it is channelled rather than continuous; and London Clay was reached in every
hole, at between 4.00 m and 8.60 m. The terrace gravel sits between the two, and
its base is the London Clay surface - which is the number the foundation design
turns on.

### Getting it right

**Group on `GEOL_GEOL`, not `GEOL_DESC`.** The formation is a controlled value;
the description is prose. Two loggers describing the same clay write different
sentences, so grouping on the description gives you a long list of strata that
each appear once and correlate with nothing.

**Say how many beds you left out.** Not every bed carries a formation. In the
run above 23 of 132 did not, and on some projects it is closer to half - a
summary that quietly drops them looks more confident than it deserves.

**Summarise one site at a time.** Geology is spatial. Averaging strata across
several sites mixes ground that may be a hundred kilometres apart, and the
result reads like insight without being any.
