BDS — Manual Calculate Tree Selection

Trend → BDS → Manual. The user hand-picks trees from a spatial map or a per-tree list, sees the residual stand + 30-year projection update live, then saves and compares selections as named projects.

Base URL: https://api.wildvine.kotaksakti.com
Setup first. The Hectare + Year inputs come from the shared /v3/bds/compartments and /v3/bds/years endpoints, and the user reaches this page from /v3/bds/simulation (BDS Page 1). See the Overview.

On this page

Trend → BDS → Manual — page-by-page flow

How the Manual (MCTS — Manual Calculate Tree Selection) journey maps onto the API, screen by screen. Each page lists the endpoint(s) the FE calls and what comes back. The Manual branch is reached from the BDS Simulation page after the user chooses Manual instead of Auto calculate.

#Page / screenWhat the user doesEndpoint(s) calledReturns / used for
0 Setup (shared with Auto) Pick the compartment (Hectare) and census Year from the two dropdowns. GET /v3/bds/compartments
GET /v3/bds/years?compartment_hectare=
Populate the Hectare dropdown (2/50) and the Year dropdown (only years that actually have data).
1 BDS Simulation (Pre-Felling) Reviews the Pre-Felling inventory, distribution charts and Candidate Trees, then taps Manual calculate. GET /v3/bds/simulation?year=&compartment_hectare= PF metric cards, species-group & DBH-class charts, Candidate-Trees table. This is the context the user harvests from.
2 Manual selection — map & list Picks specific trees by clicking points on the spatial map or rows in the per-tree table. Selection can be edited freely. Pickable list: GET /v3/bds/candidate-trees?year=&compartment_hectare= (the trees eligible to fell — table + map points).
On every selection change: GET /v3/bds/manual-selection?year=&compartment_hectare=&tree_tags=…
selected_trees + selected_total (harvest table & totals row), residual_stand (Immediate Implication panel), logging_damage, and the 30-yr future_implication chart. config.invalid_tags flags any tag that isn't a candidate; config.candidate_count is the pool size.
3 Save as project Names the current selection and saves it. POST /v3/bds/projects Persists only the inputs (year, compartment_hectare, tree_tags) under the caller. The payload is always re-derived on read, so a saved project never goes stale.
4 Saved projects list Browses their saved projects; opens, renames, or deletes one. GET /v3/bds/projects (list)
GET /v3/bds/projects/{id} (open)
PATCH /v3/bds/projects/{id} (rename)
DELETE /v3/bds/projects/{id} (remove)
Paginated summaries (newest first); opening one replays the full manual-selection payload so the screen renders exactly like page 2.
5 Compare projects Selects 2–3 saved projects (the Selected: 0/3 counter) and compares them. GET /v3/bds/projects/compare?project_ids=… Per-project residual_stand + future_implication aligned on a relative years-ahead axis (+5…+30), plus server-side pairwise deltas (a_minus_b, …).
Spatial vs list is a FE concern only. Both modes end up as a list of tree_tags sent to manual-selection; the backend doesn't distinguish them. The pickable rows (table + map) come from /v3/bds/candidate-trees — only trees eligible to fell, not all of /v3/flora. (You may still use /v3/flora to render non-selectable context trees on the map if desired.)
Projects are per-user. The /v3/bds/projects* endpoints scope everything to the Cognito user (sub) — a caller only ever sees their own projects. No token (or, for local dev, no X-User-Id header) → 401.

GET/v3/bds/candidate-trees

The pickable per-tree list for Manual selection — only trees eligible to fell. The count matches the Candidate Trees (CT) panel on the Pre-Felling (simulation) page. Use this for the table and the map markers; /v3/flora can't reproduce it because the cutting limit differs per group.

Parameters

NameTypeRequiredDescription
yearintegerrequiredCensus year, e.g. 2021
compartment_hectareintegerrequired2 or 50

Eligibility (what makes a tree a candidate)

RuleValue
Species groupDipterocarp, Non-dipterocarp, or Chengal only
Protectionredlist = 0 (protected species excluded)
Cutting limit (DBH)Dipterocarp ≥ 65 cm · Non-dipterocarp ≥ 55 cm · Chengal ≥ 70 cm

Example request

curl "https://api.wildvine.kotaksakti.com/v3/bds/candidate-trees?year=2011&compartment_hectare=50"

Example response

{
  "data": [
    {
      "tag": "21144", "scientific_name": "Diospyros singaporensis", "species_group": "Non-dipterocarp",
      "quad": string, "xco": float, "yco": float,   // local plot-grid metres (both plots)
      "longitude": float|null, "latitude": float|null,  // geographic (both 2ha & 50ha); null only for the rare ungeoreferenced tree
      "dbh_cm": 147.1,
      "basal_area_m2": float, "basal_area_m2_per_ha": float,
      "volume_m3": float,     "volume_m3_per_ha": float,
      "biomass_t": float,     "biomass_t_per_ha": float,
      "carbon_tC": float,     "carbon_tC_per_ha": float,
      "price_rm": float
    }
    // ... sorted by DBH descending
  ],
  "count": 42,                 // total eligible-to-fell; matches the CT panel
  "meta": { "compartment_hectare": 50, "year": 2011, "updated_at": "ISO-8601" }
}
Map coordinates: use longitude/latitude for the geographic (Mapbox) map — now available for both 2ha and 50ha (50ha was georeferenced in the v220 dataset). A handful of trees may still be null if ungeoreferenced. Both plots also carry xco/yco (metres within the plot grid) as a fallback / local-grid view.
Manual selection only accepts tags from this list — any other tag (e.g. a sub-cutting-limit Dipterocarp, or a protected species) is rejected into manual-selection.config.invalid_tags. No-data year → { "data": [], "count": 0 }; invalid compartment → 400.

GET/v3/bds/manual-selection

Manual Calculate Tree Selection (MCTS). The user picks specific trees from the map (spatial click) or the per-tree table (list click), and the FE sends those tags to this endpoint. Returns the same shape as Auto for a single selection (no Heavy/Medium/Light wrapper).

Parameters

NameTypeRequiredDescription
yearintegerrequiredCensus year, e.g. 2021
compartment_hectareintegerrequired2 or 50
tree_tagsstring[]optionalTag(s) of the trees the user picked. Comma-separated ?tree_tags=2317,2378 OR repeated ?tree_tags=2317&tree_tags=2378. Empty list returns an empty selection with full PF as residual (useful as a "no-harvest" baseline).

Business rules

RuleValue
Eligible treesCandidate Trees only (see /v3/bds/candidate-trees) — CT groups, redlist=0, DBH ≥ group cutting limit
Invalid-tag handlingTags that aren't candidates are returned in config.invalid_tags; the rest still process
Residual standComputed over the full Pre-Felling stand (PF minus the selected candidates, then logging damage)
Logging damageSame %s as Auto (20/30/40/50 by DBH class), smallest-DBH dropped first per class
Future projection30 years, every 5 years (6 data points); filtered to DBH≥30 each step

Example request — list-click selection (3 tags)

curl "https://api.wildvine.kotaksakti.com/v3/bds/manual-selection?year=2021&compartment_hectare=2&tree_tags=3010,2378,2843"

Example request — spatial selection (tags from map click)

curl "https://api.wildvine.kotaksakti.com/v3/bds/manual-selection?year=2021&compartment_hectare=2&tree_tags=3010&tree_tags=2378&tree_tags=2843&tree_tags=3346&tree_tags=2324"

Response structure

{
  "config": {
    "selection_mode":    "manual",
    "candidate_count":   integer,       // total selectable (CT) pool — for the "must leave N" warning
    "requested_count":   integer,       // total tags the FE sent
    "actually_selected": integer,       // tags that matched a candidate tree
    "invalid_tags":      string[]       // tags that aren't candidates
  },

  // Same shape as one Auto regime block (without "regime" / "trees_per_ha" / "target_total")
  // Each selected tree carries location + both absolute AND per-ha values (per-ha = value / compartment_hectare).
  "selected_trees":    [ { "tag": "...", "scientific_name": "...", "species_group": "...",
                           "quad": string, "xco": float, "yco": float,
                           "longitude": float|null, "latitude": float|null,   // geographic (both plots; null only if ungeoreferenced)
                           "dbh_cm": float,
                           "basal_area_m2": float, "basal_area_m2_per_ha": float,
                           "volume_m3": float,     "volume_m3_per_ha": float,
                           "biomass_t": float,     "biomass_t_per_ha": float,
                           "carbon_tC": float,     "carbon_tC_per_ha": float,
                           "price_rm": float } ],
  "selected_total":    { "tree_count": integer, "basal_area_m2": float, ..., "price_rm": float },
  "residual_stand":    { "tree_density": integer, "tree_density_per_ha": float,
                         "volume_m3_per_ha": float, "basal_area_m2_per_ha": float,
                         "total_biomass_t_per_ha": float, "total_carbon_tC_per_ha": float,
                         "remaining_species_count": integer,   // distinct species left standing
                         "carbon_loss_tC_per_ha": float },      // PF carbon/ha − residual carbon/ha
  "logging_damage": {
    "by_dbh_class": [
      { "class": "+60cm",   "removed_pct": 20, "trees_removed": integer },
      { "class": "45-60cm", "removed_pct": 30, "trees_removed": integer },
      { "class": "30-45cm", "removed_pct": 40, "trees_removed": integer },
      { "class": "15-30cm", "removed_pct": 50, "trees_removed": integer }
    ],
    "total_trees_removed": integer
  },
  "future_implication": {
    "years":                 integer[6],
    "tree_density_per_ha":   float[6],
    "volume_m3_per_ha":      float[6],
    "basal_area_m2_per_ha":  float[6],
    "biomass_t_per_ha":      float[6],
    "carbon_tC_per_ha":      float[6],
    "carbon_loss_tC_per_ha": float[6],   // no-harvest baseline projection − residual projection, per year
    "remaining_species_count_per_ha": integer[6]   // projected richness (see note) — absolute count despite the _per_ha name
  },
  "meta": { "compartment_hectare": integer, "year": integer, "updated_at": "ISO-8601" }
}
New fields: per-tree *_per_ha on selected_trees; residual_stand.remaining_species_count & residual_stand.carbon_loss_tC_per_ha; and the future_implication.carbon_loss_tC_per_ha and remaining_species_count_per_ha series.
About remaining_species_count_per_ha: the cohort projection has no local-extinction mechanism — each species self-regenerates (ingrowth ∝ its own stock, no immigration), so projected richness is constant over the 30 yr. The series is the residual's remaining_species_count repeated across all 6 steps (the chart will be a flat line). The _per_ha suffix is kept for FE array-iteration convention only — it is an absolute species count, not a per-hectare value. A genuinely declining richness curve would need a species-resolved extinction model (separate effort).

Example response (2ha 2021, 5 tags picked + 2 fake)

{
  "config": {
    "selection_mode": "manual",
    "candidate_count": 29,
    "requested_count": 7,
    "actually_selected": 5,
    "invalid_tags": ["NOTREAL999", "XX"]
  },
  "selected_trees": [
    {
      "tag": "3010",
      "scientific_name": "Koompassia malaccensis",
      "species_group": "Non-dipterocarp",
      "dbh_cm": 119.6,
      "basal_area_m2": 1.12359,
      "volume_m3": 14.60669,
      "biomass_t": 24.12313,
      "carbon_tC": 11.33787,
      "price_rm": 464.83
    }
    // ... 4 more
  ],
  "selected_total": {
    "tree_count": 5,
    "basal_area_m2": 4.32018,
    "basal_area_m2_per_ha": 2.16009,
    "volume_m3": 50.61,
    "volume_m3_per_ha": 25.305,
    "total_biomass_t": 82.4,
    "total_biomass_t_per_ha": 41.2,
    "total_carbon_tC": 38.73,
    "total_carbon_tC_per_ha": 19.37,
    "price_rm": 2414.63
  },
  "residual_stand": {
    "tree_density": 119,
    "tree_density_per_ha": 59.5,
    "volume_m3_per_ha": 70.4,
    "basal_area_m2_per_ha": 9.8,
    "total_biomass_t_per_ha": 145.2,
    "total_carbon_tC_per_ha": 68.2
  },
  "logging_damage": {
    "by_dbh_class": [
      { "class": "+60cm",   "removed_pct": 20, "trees_removed": 7 },
      { "class": "45-60cm", "removed_pct": 30, "trees_removed": 12 },
      { "class": "30-45cm", "removed_pct": 40, "trees_removed": 42 },
      { "class": "15-30cm", "removed_pct": 50, "trees_removed": 0 }
    ],
    "total_trees_removed": 61
  },
  "future_implication": {
    "years":                [2026, 2031, 2036, 2041, 2046, 2051],
    "tree_density_per_ha":  [54.2, 49.1, 45.0, 41.7, 39.0, 36.8],
    "volume_m3_per_ha":     [73.4, 76.2, 78.5, 80.3, 81.7, 82.7],
    "basal_area_m2_per_ha": [12.87, 12.71, 12.48, 12.23, 11.95, 11.67],
    "biomass_t_per_ha":     [152.3, 158.1, 162.7, 166.4, 169.3, 171.5],
    "carbon_tC_per_ha":     [71.6, 74.3, 76.5, 78.2, 79.6, 80.6]
  },
  "meta": { "compartment_hectare": 2, "year": 2021, "updated_at": "2026-06-08T08:00:00Z" }
}

Error response

{ "detail": "compartment_hectare=99 is not valid. Valid options: [2, 50]" }
Spatial vs list selection — backend doesn't care which one. Both the table and the map markers are sourced from /v3/bds/candidate-trees (only eligible-to-fell trees); the FE collects the picked tags from a row click (list mode) or a map click (spatial mode) and sends them here. One endpoint serves both.
Empty tree_tags returns a valid response where selected_trees is empty and residual_stand equals PF minus LD damage. Useful as a "no-harvest" baseline to compare against an actual selection.

Saved Manual Projects

CRUD + comparison for saved MCTS selections. Every project stores only its inputs (year, compartment_hectare, tree_tags); the manual-selection payload is re-derived on read, so a project is always consistent with the latest dataset. All of these endpoints are per-user — scoped to the Cognito sub.

Local dev auth: there's no Cognito in front of local uvicorn, so send X-User-Id: <any-uuid> to stand in for the authenticated user. In prod the FE just sends the normal Authorization: Bearer <jwt> and the user is taken from the token. Missing both → 401.

POST/v3/bds/projects

Save the current Manual-mode tree selection as a named project (page 3 of the flow).

Request body

FieldTypeRequiredDescription
namestringrequiredDisplay name, max 80 chars, non-blank.
yearintegerrequiredCensus year of the selection.
compartment_hectareintegerrequired2 or 50.
tree_tagsstring[]optionalThe picked tags. De-duplicated server-side; max 5000. Empty list is allowed (a "no-harvest" baseline).

Example request

curl -X POST "https://api.wildvine.kotaksakti.com/v3/bds/projects" \
  -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  -d '{"name":"KSP1 — Heavy Dipterocarp","year":2021,"compartment_hectare":2,"tree_tags":["3010","2378","2843"]}'

Example response 201 Created

{
  "project_id":          "1781011168554-7bb4a53e",
  "name":                "KSP1 — Heavy Dipterocarp",
  "year":                2021,
  "compartment_hectare": 2,
  "tree_tag_count":      3,
  "created_at":          "2026-06-09T13:19:28.554005+00:00",
  "updated_at":          "2026-06-09T13:19:28.554005+00:00",
  "tree_tags":           ["3010", "2378", "2843"]
}
project_id is <epoch_ms>-<random>, so it sorts chronologically. Validation errors (blank name, bad compartment_hectare, too many tags) return 400 with a detail message.

GET/v3/bds/projects

Paginated list of the caller's saved projects, newest first. Soft-deleted projects are excluded.

Parameters

NameTypeRequiredDescription
pageintegeroptional1-based page number (default 1).
page_sizeintegeroptionalDefault 20, max 100.

Example request

curl "https://api.wildvine.kotaksakti.com/v3/bds/projects?page=1&page_size=20" \
  -H "Authorization: Bearer <jwt>"

Example response

{
  "items": [
    {
      "project_id":          "1781011168684-12ec9621",
      "name":                "KSP2 — Light",
      "year":                2019,
      "compartment_hectare": 2,
      "tree_tag_count":      2,
      "created_at":          "2026-06-09T13:19:28.684000+00:00",
      "updated_at":          "2026-06-09T13:19:28.684000+00:00"
    },
    {
      "project_id":          "1781011168554-7bb4a53e",
      "name":                "KSP1 — Heavy Dipterocarp",
      "year":                2021,
      "compartment_hectare": 2,
      "tree_tag_count":      3,
      "created_at":          "2026-06-09T13:19:28.554005+00:00",
      "updated_at":          "2026-06-09T13:19:28.554005+00:00"
    }
  ],
  "page": 1, "page_size": 20, "total": 2, "total_pages": 1,
  "has_next": false, "has_prev": false
}

List items are summaries (no tree_tags array). Fetch a single project to get its tags + replayed payload.

GET/v3/bds/projects/{project_id}

Fetch a saved project together with its replayed manual-selection payload — i.e. the project's inputs are run back through manual-selection so the screen renders identically to page 2.

Example request

curl "https://api.wildvine.kotaksakti.com/v3/bds/projects/1781011168554-7bb4a53e" \
  -H "Authorization: Bearer <jwt>"

Example response

{
  "project_id":          "1781011168554-7bb4a53e",
  "name":                "KSP1 — Heavy Dipterocarp",
  "year":                2021,
  "compartment_hectare": 2,
  "tree_tag_count":      3,
  "created_at":          "2026-06-09T13:19:28.554005+00:00",
  "updated_at":          "2026-06-09T13:19:28.554005+00:00",
  "tree_tags":           ["3010", "2378", "2843"],

  // identical shape to GET /v3/bds/manual-selection
  "payload": {
    "config":             { "selection_mode": "manual", "candidate_count": 42, "requested_count": 3,
                            "actually_selected": 3, "invalid_tags": [] },
    "selected_trees":     [ /* ... */ ],
    "selected_total":     { "tree_count": 3, "...": "..." },
    "residual_stand":     { "tree_density": 121, "...": "..." },
    "logging_damage":     { "by_dbh_class": [ /* ... */ ], "total_trees_removed": 62 },
    "future_implication": { "years": [2026, 2031, 2036, 2041, 2046, 2051], "...": "..." },
    "meta":               { "compartment_hectare": 2, "year": 2021, "updated_at": "..." }
  }
}
{ "detail": "Project not found" }   // 404 — unknown id, soft-deleted, or another user's project

PATCH/v3/bds/projects/{project_id}

Rename a saved project. name is the only mutable field.

Example request

curl -X PATCH "https://api.wildvine.kotaksakti.com/v3/bds/projects/1781011168554-7bb4a53e" \
  -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  -d '{"name":"KSP1 — renamed"}'

Example response

{
  "project_id": "1781011168554-7bb4a53e",
  "name":       "KSP1 — renamed",
  "year": 2021, "compartment_hectare": 2, "tree_tag_count": 3,
  "created_at": "2026-06-09T13:19:28.554005+00:00",
  "updated_at": "2026-06-09T13:25:02.110000+00:00",
  "tree_tags":  ["3010", "2378", "2843"]
}

DELETE/v3/bds/projects/{project_id}

Soft-delete a project — the row is retained with a deleted_at stamp but disappears from list/get/compare.

Example request

curl -X DELETE "https://api.wildvine.kotaksakti.com/v3/bds/projects/1781011168684-12ec9621" \
  -H "Authorization: Bearer <jwt>"

Returns 204 No Content on success, 404 if the project doesn't exist (or already deleted / not the caller's).

GET/v3/bds/projects/compare

Side-by-side comparison of 2–3 saved projects (page 5 of the flow — the Selected: 0/3 picker). Each project's 30-yr projection is aligned on a relative years-ahead axis (+5, +10, …, +30) so projects baselined at different census years stay comparable. Server-side pairwise deltas are included.

Parameters

NameTypeRequiredDescription
project_idsstring[]required2–3 project IDs. Comma-separated ?project_ids=a,b OR repeated ?project_ids=a&project_ids=b. <2 or >3 → 400.

Example request

curl "https://api.wildvine.kotaksakti.com/v3/bds/projects/compare?project_ids=1781011168554-7bb4a53e,1781011168684-12ec9621" \
  -H "Authorization: Bearer <jwt>"

Response structure

{
  "labels":          ["a", "b", "c"],   // one per project, in request order
  "best_project_id": string,            // the "best option" to highlight (see below); null if <2 comparable
  "projects": [
    {
      "project_id": string, "name": string,
      "year": integer, "compartment_hectare": integer,
      "tree_tag_count": integer,
      "selected_total":  { /* ... same as manual-selection ... */ },
      "residual_stand":  { /* tree_density, *_per_ha, remaining_species_count, carbon_loss_tC_per_ha */ },
      "future_implication": {
        "years_absolute":        integer[6],   // this project's calendar years
        "years_ahead":           [5,10,15,20,25,30],
        "tree_density_per_ha":   float[6],
        "volume_m3_per_ha":      float[6],
        "basal_area_m2_per_ha":  float[6],
        "biomass_t_per_ha":      float[6],
        "carbon_tC_per_ha":      float[6],
        "carbon_loss_tC_per_ha": float[6],
        "remaining_species_count_per_ha": integer[6]   // flat — see Manual notes
      }
    }
    // null entry if a requested project is missing / deleted
  ],
  "deltas": {
    "residual_stand":     { "<metric>": { "a_minus_b": float, ... } },     // incl. remaining_species_count, carbon_loss_tC_per_ha
    "future_implication": { "<metric>": { "a_minus_b": float[6], ... } }   // incl. carbon_loss_tC_per_ha, remaining_species_count_per_ha
  },
  "alignment": {
    "axis": "years_ahead", "scale": "relative_to_project_year",
    "values": [5,10,15,20,25,30], "note": "..."
  }
}
best_project_id — equal-weight normalized composite across the compared projects: higher residual volume / remaining species / biomass / carbon is better, higher carbon loss is worse. Each metric is min-max normalized to [0,1] across the set and averaged; the highest mean wins. Because all factors reward keeping more standing, the lightest-harvest project tends to win — confirm this matches the intended "best" semantics.

Example response (abbreviated, 2 projects)

{
  "labels": ["a", "b"],
  "projects": [
    { "name": "KSP1 — Heavy Dipterocarp", "year": 2021,
      "residual_stand": { "tree_density_per_ha": 60.5, "...": "..." },
      "future_implication": { "years_absolute": [2026,2031,2036,2041,2046,2051],
                              "years_ahead": [5,10,15,20,25,30], "...": "..." } },
    { "name": "KSP2 — Light", "year": 2019,
      "residual_stand": { "tree_density_per_ha": 62.5, "...": "..." },
      "future_implication": { "years_absolute": [2024,2029,2034,2039,2044,2049],
                              "years_ahead": [5,10,15,20,25,30], "...": "..." } }
  ],
  "deltas": {
    "residual_stand":     { "tree_density_per_ha": { "a_minus_b": -2.0 } },
    "future_implication": { "tree_density_per_ha": { "a_minus_b": [ /* 6 vals */ ] } }
  },
  "alignment": { "axis": "years_ahead", "scale": "relative_to_project_year",
                 "values": [5,10,15,20,25,30] }
}
Why a relative axis? Project A baselined at 2021 and Project B at 2019 still line up at "+5y, +10y…", so the comparison is apples-to-apples even though their calendar years differ. Use years_absolute only for axis labels if you want real years.

Last updated 2026-06-09 · Hosted on Cloudflare Pages