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.
/v3/bds/compartments and
/v3/bds/years endpoints, and the user reaches this page from
/v3/bds/simulation (BDS Page 1). See the Overview.
/v3/bds/candidate-trees — the pickable per-tree list (eligible to fell)/v3/bds/manual-selection — compute a selection from a tag list/v3/bds/projects — save the current selection as a named project/v3/bds/projects — list the caller's saved projects/v3/bds/projects/{id} — saved project + replayed payload/v3/bds/projects/{id} — rename a saved project/v3/bds/projects/{id} — soft-delete a saved project/v3/bds/projects/compare — compare 2–3 saved projectsHow 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 / screen | What the user does | Endpoint(s) called | Returns / used for |
|---|---|---|---|---|
| 0 | Setup (shared with Auto) | Pick the compartment (Hectare) and census Year from the two dropdowns. | GET /v3/bds/compartmentsGET /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, …). |
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.)
/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.
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.
| Name | Type | Required | Description |
|---|---|---|---|
year | integer | required | Census year, e.g. 2021 |
compartment_hectare | integer | required | 2 or 50 |
| Rule | Value |
|---|---|
| Species group | Dipterocarp, Non-dipterocarp, or Chengal only |
| Protection | redlist = 0 (protected species excluded) |
| Cutting limit (DBH) | Dipterocarp ≥ 65 cm · Non-dipterocarp ≥ 55 cm · Chengal ≥ 70 cm |
curl "https://api.wildvine.kotaksakti.com/v3/bds/candidate-trees?year=2011&compartment_hectare=50"
{
"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" }
}
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.config.invalid_tags.
No-data year → { "data": [], "count": 0 }; invalid compartment → 400.
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).
| Name | Type | Required | Description |
|---|---|---|---|
year | integer | required | Census year, e.g. 2021 |
compartment_hectare | integer | required | 2 or 50 |
tree_tags | string[] | optional | Tag(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). |
| Rule | Value |
|---|---|
| Eligible trees | Candidate Trees only (see /v3/bds/candidate-trees) — CT groups, redlist=0, DBH ≥ group cutting limit |
| Invalid-tag handling | Tags that aren't candidates are returned in config.invalid_tags; the rest still process |
| Residual stand | Computed over the full Pre-Felling stand (PF minus the selected candidates, then logging damage) |
| Logging damage | Same %s as Auto (20/30/40/50 by DBH class), smallest-DBH dropped first per class |
| Future projection | 30 years, every 5 years (6 data points); filtered to DBH≥30 each step |
curl "https://api.wildvine.kotaksakti.com/v3/bds/manual-selection?year=2021&compartment_hectare=2&tree_tags=3010,2378,2843"
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"
{
"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" }
}
*_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.
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).
{
"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" }
}
{ "detail": "compartment_hectare=99 is not valid. Valid options: [2, 50]" }
/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.
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.
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.
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.
Save the current Manual-mode tree selection as a named project (page 3 of the flow).
| Field | Type | Required | Description |
|---|---|---|---|
name | string | required | Display name, max 80 chars, non-blank. |
year | integer | required | Census year of the selection. |
compartment_hectare | integer | required | 2 or 50. |
tree_tags | string[] | optional | The picked tags. De-duplicated server-side; max 5000. Empty list is allowed (a "no-harvest" baseline). |
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"]}'
{
"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.
Paginated list of the caller's saved projects, newest first. Soft-deleted projects are excluded.
| Name | Type | Required | Description |
|---|---|---|---|
page | integer | optional | 1-based page number (default 1). |
page_size | integer | optional | Default 20, max 100. |
curl "https://api.wildvine.kotaksakti.com/v3/bds/projects?page=1&page_size=20" \ -H "Authorization: Bearer <jwt>"
{
"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.
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.
curl "https://api.wildvine.kotaksakti.com/v3/bds/projects/1781011168554-7bb4a53e" \ -H "Authorization: Bearer <jwt>"
{
"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
Rename a saved project. name is the only mutable field.
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"}'
{
"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"]
}
Soft-delete a project — the row is retained with a deleted_at stamp but disappears from list/get/compare.
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).
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.
| Name | Type | Required | Description |
|---|---|---|---|
project_ids | string[] | required | 2–3 project IDs. Comma-separated ?project_ids=a,b OR repeated ?project_ids=a&project_ids=b. <2 or >3 → 400. |
curl "https://api.wildvine.kotaksakti.com/v3/bds/projects/compare?project_ids=1781011168554-7bb4a53e,1781011168684-12ec9621" \ -H "Authorization: Bearer <jwt>"
{
"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.
{
"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] }
}
years_absolute only for axis labels if you want real years.
Last updated 2026-06-09 · Hosted on Cloudflare Pages