diff --git a/BACKLOG.md b/BACKLOG.md index 690acbcd..fb8fac1d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -171,7 +171,7 @@ Description formats: | 063 | Infrastructure | [Analyse tool specs for phased implementation sequence](docs/ideas/063-tool-implementation-sequence.md) [E01] — dependency graph, phase groupings, and per-phase backlog items for 63 documented tools | 5 | 3 | 4 | 12 | Medium | approved | | 085 | Feature | [Chart renderer + dataset-to-spec transformer](docs/ideas/E04-results-visualization.md) [E04] — React component with Vega-Lite (swappable); transformer converts standard result datasets to render specs | 4 | 4 | 4 | 12 | Medium | approved | | 079 | Feature | [Implement move-track tool](docs/ideas/E03-buffer-zone-analysis-demo.md) [E03] — offset track by range/bearing with map drag support (requires #049, #062) | 4 | 4 | 4 | 12 | Medium | approved | -| 081 | Feature | [Implement point-in-zone-classifier tool](docs/ideas/E03-buffer-zone-analysis-demo.md) [E03] — classify and recolor reference points by buffer zone membership (requires #049, #078, #080) | 4 | 4 | 4 | 12 | Medium | approved | +| 081 | Feature | [Implement point-in-zone-classifier tool](specs/081-point-in-zone-classifier/spec.md) [E03] — classify and recolor reference points by buffer zone membership (requires #049, #078, #080) | 4 | 4 | 4 | 12 | Medium | specified | | 082 | Feature | [Implement zone-histogram-generator tool](docs/ideas/E03-buffer-zone-analysis-demo.md) [E03] — outputs dataset/zone_histogram, point counts per buffer zone (requires #049, #081) | 4 | 4 | 4 | 12 | Medium | approved | | 094 | Feature | [[E05] Implement point and rectangle drawing](docs/ideas/094-point-rectangle-drawing.md) (requires #091, #092, #093) [E05] | 4 | 4 | 4 | 12 | Medium | approved | | 011 | Documentation | Create Jupyter notebook example demonstrating debrief-calc Python API | 4 | 4 | 4 | 12 | Low | approved | diff --git a/apps/vscode/src/tools/reference/classification/index.ts b/apps/vscode/src/tools/reference/classification/index.ts new file mode 100644 index 00000000..ec362a2d --- /dev/null +++ b/apps/vscode/src/tools/reference/classification/index.ts @@ -0,0 +1,10 @@ +/** + * Reference point classification tools barrel file. + * + * Exports tool definitions and execute functions for point-in-zone classification. + */ + +export { + toolDefinition as pointInZoneClassifierDefinition, + execute as pointInZoneClassifierExecute, +} from './pointInZoneClassifier'; diff --git a/apps/vscode/src/tools/reference/classification/pointInZoneClassifier.ts b/apps/vscode/src/tools/reference/classification/pointInZoneClassifier.ts new file mode 100644 index 00000000..a6849a90 --- /dev/null +++ b/apps/vscode/src/tools/reference/classification/pointInZoneClassifier.ts @@ -0,0 +1,180 @@ +/** + * Point-in-Zone Classifier tool implementation. + * + * Classifies reference points by buffer zone membership using ray-casting + * point-in-polygon testing. Step 4 of the E03 buffer zone analysis chain. + */ + +import type { MCPToolDefinition } from '../../../types/tool'; + +/** Default color for points outside all zones. */ +const DEFAULT_COLOR = '#666666'; +const DEFAULT_ZONE = 'none'; + +interface GeoJSONFeature { + type: 'Feature'; + id?: string; + geometry: { type: string; coordinates: unknown }; + properties: Record; +} + +interface PointMetadataEntry { + index: number; + name: string; + zone?: string; + color?: string; + [key: string]: unknown; +} + +interface ZoneMetadata { + name: string; + detection_likelihood_pct: number; + buffer_distance_nm: number; + style: { fill_color?: string; color?: string }; +} + +export const toolDefinition: MCPToolDefinition = { + name: 'point-in-zone-classifier', + description: + 'Classify reference points by detection zone membership. Tests each coordinate against concentric zone polygons (innermost first) and updates per-point metadata with zone name and color.', + inputSchema: { + type: 'object', + properties: { + features: { type: 'array', items: { type: 'object' } }, + params: { + type: 'object', + properties: {}, + }, + }, + }, + annotations: { + 'debrief:selectionRequirements': [ + { kind: 'POINT', min: 1, max: 1 }, + { kind: 'ZONE', min: 1, max: 1 }, + ], + 'debrief:category': 'reference/classification', + 'debrief:version': '1.0.0', + 'debrief:outputKind': 'mutation/reference/classified_points', + }, +}; + +/** + * Ray-casting point-in-polygon test. + * + * Casts a horizontal ray to the right from (px, py) and counts edge crossings. + * Odd count = inside, even count = outside. + */ +function pointInPolygon(px: number, py: number, ring: number[][]): boolean { + let inside = false; + const n = ring.length; + let j = n - 1; + + for (let i = 0; i < n; i++) { + const xi = ring[i]![0]!; + const yi = ring[i]![1]!; + const xj = ring[j]![0]!; + const yj = ring[j]![1]!; + + if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) { + inside = !inside; + } + + j = i; + } + + return inside; +} + +/** Extract color from zone metadata, preferring fill_color over color. */ +function getZoneColor(zoneInfo: ZoneMetadata): string { + return zoneInfo.style.fill_color ?? zoneInfo.style.color ?? DEFAULT_COLOR; +} + +export function execute( + features: GeoJSONFeature[], + _params: Record, +): GeoJSONFeature[] { + // Find reference and zone features + let refFeature: GeoJSONFeature | null = null; + let zoneFeature: GeoJSONFeature | null = null; + + for (const feature of features) { + const props = feature.properties; + const kind = props.kind as string | undefined; + if (kind === 'POINT' && props.locationType === 'REFERENCE' && !refFeature) { + refFeature = feature; + } else if (kind === 'ZONE' && !zoneFeature) { + zoneFeature = feature; + } + } + + // Validate inputs + if (!refFeature) { + throw new Error('No reference point feature found'); + } + if (!zoneFeature) { + throw new Error('No zone feature found'); + } + + if (refFeature.geometry.type !== 'MultiPoint') { + throw new Error('Reference feature must have MultiPoint geometry'); + } + if (zoneFeature.geometry.type !== 'MultiPolygon') { + throw new Error('Zone feature must have MultiPolygon geometry'); + } + + const coordinates = refFeature.geometry.coordinates as number[][]; + const metadata = (refFeature.properties.pointMetadata ?? []) as PointMetadataEntry[]; + const zonePolygons = zoneFeature.geometry.coordinates as number[][][][]; + const zoneInfoList = (zoneFeature.properties.zones ?? []) as ZoneMetadata[]; + + if (metadata.length !== coordinates.length) { + throw new Error('pointMetadata length must match coordinates length'); + } + + // Handle empty coordinates + if (coordinates.length === 0) { + const classified = JSON.parse(JSON.stringify(refFeature)) as GeoJSONFeature; + classified.properties.pointMetadata = []; + classified.properties.pointColors = []; + return [classified]; + } + + // Classify each point + const pointColors: string[] = []; + const newMetadata: PointMetadataEntry[] = []; + + for (let i = 0; i < coordinates.length; i++) { + const px = coordinates[i]![0]!; // longitude + const py = coordinates[i]![1]!; // latitude + + let assignedZone = DEFAULT_ZONE; + let assignedColor = DEFAULT_COLOR; + + // Test zones innermost first (index 0 = highest likelihood) + for (let z = 0; z < zonePolygons.length; z++) { + if (z < zoneInfoList.length) { + const ring = zonePolygons[z]![0]!; // outer ring of this polygon + if (pointInPolygon(px, py, ring)) { + assignedZone = zoneInfoList[z]!.name; + assignedColor = getZoneColor(zoneInfoList[z]!); + break; // innermost match wins + } + } + } + + // Copy and update metadata entry (preserve existing fields) + const entry: PointMetadataEntry = { ...metadata[i]! }; + entry.zone = assignedZone; + entry.color = assignedColor; + newMetadata.push(entry); + pointColors.push(assignedColor); + } + + // Build classified feature (deep copy of original with updated metadata) + const classified = JSON.parse(JSON.stringify(refFeature)) as GeoJSONFeature; + classified.properties.pointMetadata = newMetadata; + classified.properties.pointColors = pointColors; + + return [classified]; +} diff --git a/apps/web-shell/playwright/tests/styling-tools.spec.ts b/apps/web-shell/playwright/tests/styling-tools.spec.ts index 64bc6066..131b47e2 100644 --- a/apps/web-shell/playwright/tests/styling-tools.spec.ts +++ b/apps/web-shell/playwright/tests/styling-tools.spec.ts @@ -45,9 +45,9 @@ test.describe('Styling Tools Integration', () => { }); }); - test('tools panel lists all 13 tools (2 built-in + 4 styling + 1 shape + 1 reference + 1 sensor + 1 track-manipulation + 3 analysis)', async ({ page }) => { + test('tools panel lists all 14 tools (2 built-in + 4 styling + 1 shape + 2 reference + 1 sensor + 1 track-manipulation + 3 analysis)', async ({ page }) => { const toolItems = page.locator('.debrief-tools-panel__item'); - await expect(toolItems).toHaveCount(13); + await expect(toolItems).toHaveCount(14); }); test('styling tools are listed by name', async ({ page }) => { diff --git a/apps/web-shell/src/services/toolService.ts b/apps/web-shell/src/services/toolService.ts index 3a24a9a8..64121631 100644 --- a/apps/web-shell/src/services/toolService.ts +++ b/apps/web-shell/src/services/toolService.ts @@ -112,6 +112,11 @@ import { execute as executeAreaSummary, } from '../tools/region/analysis/areaSummary'; +import { + toolDefinition as pointInZoneClassifierDef, + execute as executePointInZoneClassifier, +} from '../../../vscode/src/tools/reference/classification/pointInZoneClassifier'; + // Re-export types for consumers export type { MCPToolDefinition, MCPToolResponse, MCPContentItem, DebriefAnnotations }; @@ -219,6 +224,13 @@ const toolRegistry: Map = new Map([ execute: executeAreaSummary, }, ], + [ + pointInZoneClassifierDef.name, + { + definition: pointInZoneClassifierDef, + execute: executePointInZoneClassifier, + }, + ], ]); /** diff --git a/services/calc/debrief_calc/tools/__init__.py b/services/calc/debrief_calc/tools/__init__.py index be78cbc1..55dc2d91 100644 --- a/services/calc/debrief_calc/tools/__init__.py +++ b/services/calc/debrief_calc/tools/__init__.py @@ -15,11 +15,12 @@ - generate-reference-points: Generate grid/scatter reference points in a bounding box - generate-courses-speeds: Derive course and speed from consecutive positions - buffer-zone-generator: Generate detection likelihood buffer zones around a track +- point-in-zone-classifier: Classify reference points by buffer zone membership """ # Import tools to trigger registration via @tool decorator from debrief_calc.tools import area_summary, range_bearing, track_stats -from debrief_calc.tools.reference import generation +from debrief_calc.tools.reference import classification, generation from debrief_calc.tools.sensor import detection from debrief_calc.tools.shape import manipulation from debrief_calc.tools.track import styling @@ -30,6 +31,7 @@ "area_summary", "styling", "manipulation", + "classification", "generation", "detection", ] diff --git a/services/calc/debrief_calc/tools/reference/__init__.py b/services/calc/debrief_calc/tools/reference/__init__.py index 7546a677..7bff9738 100644 --- a/services/calc/debrief_calc/tools/reference/__init__.py +++ b/services/calc/debrief_calc/tools/reference/__init__.py @@ -4,6 +4,6 @@ Tools for generating and managing reference point sets. """ -from debrief_calc.tools.reference import generation +from debrief_calc.tools.reference import classification, generation -__all__ = ["generation"] +__all__ = ["classification", "generation"] diff --git a/services/calc/debrief_calc/tools/reference/classification.py b/services/calc/debrief_calc/tools/reference/classification.py new file mode 100644 index 00000000..a1c05fa1 --- /dev/null +++ b/services/calc/debrief_calc/tools/reference/classification.py @@ -0,0 +1,152 @@ +""" +Point-in-Zone Classifier tool. + +Classifies reference points by buffer zone membership using ray-casting +point-in-polygon testing. Step 4 of the E03 buffer zone analysis chain. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from debrief_calc.models import ContextType, SelectionContext +from debrief_calc.registry import tool + +# Default color for points outside all zones +_DEFAULT_COLOR = "#666666" +_DEFAULT_ZONE = "none" + + +def _point_in_polygon(px: float, py: float, ring: list[list[float]]) -> bool: + """Ray-casting point-in-polygon test. + + Casts a horizontal ray to the right from (px, py) and counts edge crossings. + Odd count = inside, even count = outside. + """ + inside = False + n = len(ring) + j = n - 1 + + for i in range(n): + xi, yi = ring[i][0], ring[i][1] + xj, yj = ring[j][0], ring[j][1] + + if ((yi > py) != (yj > py)) and (px < (xj - xi) * (py - yi) / (yj - yi) + xi): + inside = not inside + + j = i + + return inside + + +def _get_zone_color(zone_info: dict[str, Any]) -> str: + """Extract color from zone metadata, preferring fill_color over color.""" + style = zone_info.get("style", {}) + return style.get("fill_color", style.get("color", _DEFAULT_COLOR)) + + +@tool( + name="point-in-zone-classifier", + description=( + "Classify reference points by detection zone membership. " + "Tests each coordinate against concentric zone polygons (innermost first) " + "and updates per-point metadata with zone name and color." + ), + input_kinds=["POINT", "ZONE"], + output_kind="reference/classified_points", + context_type=ContextType.MULTI, + parameters=[], +) +def point_in_zone_classifier( + context: SelectionContext, params: dict[str, Any] +) -> list[dict[str, Any]]: + """ + Classify reference points by buffer zone membership. + + Args: + context: SelectionContext (MULTI — one POINT/REFERENCE and one ZONE feature) + params: Tool parameters (none required) + + Returns: + List containing the classified MultiPoint GeoJSON Feature + """ + # Find reference and zone features + ref_feature: dict[str, Any] | None = None + zone_feature: dict[str, Any] | None = None + + for feature in context.features: + props = feature.get("properties", {}) + kind = props.get("kind") + if kind == "POINT" and props.get("locationType") == "REFERENCE" and ref_feature is None: + ref_feature = feature + elif kind == "ZONE" and zone_feature is None: + zone_feature = feature + + # Validate inputs + if ref_feature is None: + raise ValueError("No reference point feature found") + if zone_feature is None: + raise ValueError("No zone feature found") + + ref_geom = ref_feature.get("geometry", {}) + zone_geom = zone_feature.get("geometry", {}) + + if ref_geom.get("type") != "MultiPoint": + raise ValueError("Reference feature must have MultiPoint geometry") + if zone_geom.get("type") != "MultiPolygon": + raise ValueError("Zone feature must have MultiPolygon geometry") + + coordinates: list[list[float]] = ref_geom.get("coordinates", []) + ref_props = ref_feature.get("properties", {}) + metadata: list[dict[str, Any]] = ref_props.get("pointMetadata", []) + zone_polygons: list[list[list[list[float]]]] = zone_geom.get("coordinates", []) + zone_info_list: list[dict[str, Any]] = zone_feature.get("properties", {}).get("zones", []) + + if len(metadata) != len(coordinates): + raise ValueError("pointMetadata length must match coordinates length") + + # Handle empty coordinates + if not coordinates: + classified = copy.deepcopy(ref_feature) + classified["properties"]["pointMetadata"] = [] + classified["properties"]["pointColors"] = [] + return [classified] + + # Classify each point + point_colors: list[str] = [] + zone_counts: dict[str, int] = {} + new_metadata: list[dict[str, Any]] = [] + + for i, coord in enumerate(coordinates): + px = coord[0] # longitude + py = coord[1] # latitude + + assigned_zone = _DEFAULT_ZONE + assigned_color = _DEFAULT_COLOR + + # Test zones innermost first (index 0 = highest likelihood) + for z, polygon_rings in enumerate(zone_polygons): + if z < len(zone_info_list): + ring = polygon_rings[0] # outer ring of this polygon + if _point_in_polygon(px, py, ring): + assigned_zone = zone_info_list[z].get("name", f"zone-{z}") + assigned_color = _get_zone_color(zone_info_list[z]) + break + + # Copy and update metadata entry (preserve existing fields) + entry = dict(metadata[i]) if i < len(metadata) else {} + entry["zone"] = assigned_zone + entry["color"] = assigned_color + new_metadata.append(entry) + point_colors.append(assigned_color) + + # Track counts + zone_counts[assigned_zone] = zone_counts.get(assigned_zone, 0) + 1 + + # Build classified feature (deep copy of original with updated metadata) + classified = copy.deepcopy(ref_feature) + classified["properties"]["pointMetadata"] = new_metadata + classified["properties"]["pointColors"] = point_colors + + return [classified] diff --git a/services/calc/tests/tools/reference/test_classification.py b/services/calc/tests/tools/reference/test_classification.py new file mode 100644 index 00000000..01d7c2d4 --- /dev/null +++ b/services/calc/tests/tools/reference/test_classification.py @@ -0,0 +1,507 @@ +"""Unit tests for point-in-zone-classifier tool.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + +import pytest +from debrief_calc.models import ContextType, SelectionContext + +# Golden example paths +GOLDEN_DIR = ( + Path(__file__).parent.parent.parent.parent.parent.parent + / "shared" + / "tools" + / "reference" + / "classification" +) + + +def _load_golden(name: str) -> dict[str, Any]: + """Load a golden example JSON file.""" + path = GOLDEN_DIR / name + with open(path) as f: + return json.load(f) + + +def _make_ref_feature( + coordinates: list[list[float]], + metadata: list[dict[str, Any]] | None = None, + feature_id: str = "ref-test", +) -> dict[str, Any]: + """Create a MultiPoint reference feature.""" + if metadata is None: + metadata = [{"index": i, "name": f"Ref {i + 1}"} for i in range(len(coordinates))] + return { + "type": "Feature", + "id": feature_id, + "geometry": { + "type": "MultiPoint", + "coordinates": coordinates, + }, + "properties": { + "kind": "POINT", + "locationType": "REFERENCE", + "name": "Test Points", + "style": {"shape": "square", "color": "#666666", "radius": 5}, + "pointMetadata": metadata, + }, + } + + +def _make_zone_feature( + polygons: list[list[list[list[float]]]], + zones: list[dict[str, Any]] | None = None, + feature_id: str = "zone-test", +) -> dict[str, Any]: + """Create a MultiPolygon zone feature.""" + if zones is None: + zones = [ + { + "name": "75%", + "detection_likelihood_pct": 75, + "buffer_distance_nm": 3.0, + "style": {"fill_color": "#9C27B0", "color": "#9C27B0"}, + }, + { + "name": "50%", + "detection_likelihood_pct": 50, + "buffer_distance_nm": 6.0, + "style": {"fill_color": "#F44336", "color": "#F44336"}, + }, + { + "name": "25%", + "detection_likelihood_pct": 25, + "buffer_distance_nm": 12.0, + "style": {"fill_color": "#FF9800", "color": "#FF9800"}, + }, + ] + return { + "type": "Feature", + "id": feature_id, + "geometry": { + "type": "MultiPolygon", + "coordinates": polygons, + }, + "properties": { + "kind": "ZONE", + "name": "Detection Zones", + "zones": zones, + }, + } + + +# Simple concentric square zones for testing +# Inner zone: -1,-1 to 1,1 +# Middle zone: -2,-2 to 2,2 +# Outer zone: -3,-3 to 3,3 +_INNER_RING = [[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]] +_MIDDLE_RING = [[-2, -2], [2, -2], [2, 2], [-2, 2], [-2, -2]] +_OUTER_RING = [[-3, -3], [3, -3], [3, 3], [-3, 3], [-3, -3]] + +_SIMPLE_ZONES = [[_INNER_RING], [_MIDDLE_RING], [_OUTER_RING]] + + +def _make_context(ref_feature: dict[str, Any], zone_feature: dict[str, Any]) -> SelectionContext: + """Create a MULTI context with ref and zone features.""" + return SelectionContext( + type=ContextType.MULTI, + features=[ref_feature, zone_feature], + ) + + +# ============================================================================ +# User Story 1: Classify reference points by buffer zone +# ============================================================================ + + +class TestClassifyBasic: + """Basic classification tests.""" + + def test_point_inside_inner_zone(self): + """Point at origin should be classified as innermost zone.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0, 0]]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + assert len(result) == 1 + classified = result[0] + md = classified["properties"]["pointMetadata"] + assert md[0]["zone"] == "75%" + assert md[0]["color"] == "#9C27B0" + + def test_point_in_middle_zone(self): + """Point at (1.5, 0) should be in middle zone (outside inner, inside middle).""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[1.5, 0]]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + assert md[0]["zone"] == "50%" + assert md[0]["color"] == "#F44336" + + def test_point_in_outer_zone(self): + """Point at (2.5, 0) should be in outer zone.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[2.5, 0]]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + assert md[0]["zone"] == "25%" + assert md[0]["color"] == "#FF9800" + + def test_point_outside_all_zones(self): + """Point at (10, 10) should be outside all zones.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[10, 10]]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + assert md[0]["zone"] == "none" + assert md[0]["color"] == "#666666" + + def test_multiple_points_classified_correctly(self): + """Multiple points should each be classified independently.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + coords = [ + [0, 0], # inner + [1.5, 0], # middle + [2.5, 0], # outer + [10, 10], # outside + ] + ref = _make_ref_feature(coords) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + assert md[0]["zone"] == "75%" + assert md[1]["zone"] == "50%" + assert md[2]["zone"] == "25%" + assert md[3]["zone"] == "none" + + def test_point_colors_array_parallel(self): + """pointColors must be parallel to coordinates.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + coords = [[0, 0], [1.5, 0], [10, 10]] + ref = _make_ref_feature(coords) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + pc = result[0]["properties"]["pointColors"] + assert len(pc) == 3 + assert pc[0] == "#9C27B0" + assert pc[1] == "#F44336" + assert pc[2] == "#666666" + + def test_innermost_zone_wins(self): + """Point inside innermost zone must not be assigned to outer zones.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0.5, 0.5]]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + # Must be 75% (innermost), not 50% or 25% + assert md[0]["zone"] == "75%" + + +# ============================================================================ +# User Story 2: Preserve existing point metadata +# ============================================================================ + + +class TestMetadataPreservation: + """Metadata preservation tests.""" + + def test_preserves_index_and_name(self): + """Existing index and name fields must survive classification.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + metadata = [{"index": 0, "name": "Alpha"}, {"index": 1, "name": "Beta"}] + ref = _make_ref_feature([[0, 0], [10, 10]], metadata=metadata) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + assert md[0]["index"] == 0 + assert md[0]["name"] == "Alpha" + assert md[0]["zone"] == "75%" + assert md[1]["index"] == 1 + assert md[1]["name"] == "Beta" + assert md[1]["zone"] == "none" + + def test_preserves_custom_fields(self): + """Custom fields in metadata must survive classification.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + metadata = [{"index": 0, "name": "Ref 1", "custom": "data", "value": 42}] + ref = _make_ref_feature([[0, 0]], metadata=metadata) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + assert md[0]["custom"] == "data" + assert md[0]["value"] == 42 + assert "zone" in md[0] + assert "color" in md[0] + + def test_reclassification_updates_zone_color(self): + """Re-classification with different zones should update zone/color fields.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + # First classification — point at origin is in inner zone + ref = _make_ref_feature([[0, 0]]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result1 = point_in_zone_classifier(ctx, {}) + assert result1[0]["properties"]["pointMetadata"][0]["zone"] == "75%" + + # Re-classify with a zone that doesn't contain the origin + far_ring = [[5, 5], [6, 5], [6, 6], [5, 6], [5, 5]] + far_zones = [[far_ring]] + far_zone = _make_zone_feature( + far_zones, + zones=[ + { + "name": "remote", + "detection_likelihood_pct": 90, + "buffer_distance_nm": 1.0, + "style": {"fill_color": "#00FF00"}, + } + ], + ) + ctx2 = _make_context(result1[0], far_zone) + result2 = point_in_zone_classifier(ctx2, {}) + + md = result2[0]["properties"]["pointMetadata"] + assert md[0]["zone"] == "none" + assert md[0]["color"] == "#666666" + + def test_does_not_mutate_input(self): + """Input feature must not be mutated in place.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0, 0]]) + original_metadata = copy.deepcopy(ref["properties"]["pointMetadata"]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + point_in_zone_classifier(ctx, {}) + + # Original should be unchanged + assert ref["properties"]["pointMetadata"] == original_metadata + assert "pointColors" not in ref["properties"] + + +# ============================================================================ +# User Story 3: Determinism and cascade compatibility +# ============================================================================ + + +class TestDeterminism: + """Determinism and statelessness tests.""" + + def test_identical_inputs_produce_identical_output(self): + """Same inputs must produce identical output.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0, 0], [1.5, 0], [10, 10]]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx1 = _make_context(ref, zone) + ctx2 = _make_context(ref, zone) + result1 = point_in_zone_classifier(ctx1, {}) + result2 = point_in_zone_classifier(ctx2, {}) + + assert result1 == result2 + + def test_geometry_unchanged(self): + """Output geometry must be identical to input geometry.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + coords = [[0, 0], [1.5, 0], [2.5, 0]] + ref = _make_ref_feature(coords) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + assert result[0]["geometry"]["coordinates"] == coords + + +# ============================================================================ +# Edge Cases +# ============================================================================ + + +class TestEdgeCases: + """Edge case tests.""" + + def test_no_ref_feature_raises(self): + """Missing reference feature should raise ValueError.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = SelectionContext(type=ContextType.MULTI, features=[zone]) + with pytest.raises(ValueError, match="No reference point feature"): + point_in_zone_classifier(ctx, {}) + + def test_no_zone_feature_raises(self): + """Missing zone feature should raise ValueError.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0, 0]]) + ctx = SelectionContext(type=ContextType.MULTI, features=[ref]) + with pytest.raises(ValueError, match="No zone feature"): + point_in_zone_classifier(ctx, {}) + + def test_non_multipoint_raises(self): + """Non-MultiPoint reference feature should raise ValueError.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = { + "type": "Feature", + "id": "bad", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"kind": "POINT", "locationType": "REFERENCE", "pointMetadata": []}, + } + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + with pytest.raises(ValueError, match="MultiPoint"): + point_in_zone_classifier(ctx, {}) + + def test_non_multipolygon_raises(self): + """Non-MultiPolygon zone feature should raise ValueError.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0, 0]]) + zone = { + "type": "Feature", + "id": "bad-zone", + "geometry": {"type": "Polygon", "coordinates": [_INNER_RING]}, + "properties": {"kind": "ZONE", "zones": []}, + } + ctx = _make_context(ref, zone) + with pytest.raises(ValueError, match="MultiPolygon"): + point_in_zone_classifier(ctx, {}) + + def test_metadata_length_mismatch_raises(self): + """Mismatched pointMetadata/coordinates lengths should raise ValueError.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0, 0], [1, 1]], metadata=[{"index": 0, "name": "Ref 1"}]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + with pytest.raises(ValueError, match="pointMetadata length"): + point_in_zone_classifier(ctx, {}) + + def test_empty_coordinates(self): + """Empty coordinates should return feature with empty metadata and colors.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([], metadata=[]) + zone = _make_zone_feature(_SIMPLE_ZONES) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + classified = result[0] + assert classified["properties"]["pointMetadata"] == [] + assert classified["properties"]["pointColors"] == [] + + def test_empty_zones_array(self): + """Empty zones metadata should classify all points as 'none'.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + ref = _make_ref_feature([[0, 0]]) + zone = _make_zone_feature(_SIMPLE_ZONES, zones=[]) + ctx = _make_context(ref, zone) + result = point_in_zone_classifier(ctx, {}) + + md = result[0]["properties"]["pointMetadata"] + assert md[0]["zone"] == "none" + + +# ============================================================================ +# Golden Example Validation +# ============================================================================ + + +class TestGoldenExamples: + """Golden example validation tests.""" + + def test_basic_golden_example(self): + """Python output must match the basic golden example.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + golden_input = _load_golden("point-in-zone-classifier.basic.input.json") + golden_output = _load_golden("point-in-zone-classifier.basic.output.json") + + ctx = SelectionContext( + type=ContextType.MULTI, + features=golden_input["features"], + ) + result = point_in_zone_classifier(ctx, {}) + + # Parse expected feature from the golden output text + expected_text = golden_output["content"][0]["text"] + expected_feature = json.loads(expected_text) + + actual = result[0] + assert actual["id"] == expected_feature["id"] + assert actual["geometry"] == expected_feature["geometry"] + + # Verify per-point classification matches + actual_md = actual["properties"]["pointMetadata"] + expected_md = expected_feature["properties"]["pointMetadata"] + assert len(actual_md) == len(expected_md) + for i, (a, e) in enumerate(zip(actual_md, expected_md, strict=True)): + assert a["zone"] == e["zone"], f"Point {i} zone mismatch: {a['zone']} != {e['zone']}" + assert a["color"] == e["color"], f"Point {i} color mismatch" + + # Verify pointColors array + assert actual["properties"]["pointColors"] == expected_feature["properties"]["pointColors"] + + def test_all_outside_golden_example(self): + """Python output must match the all-outside golden example.""" + from debrief_calc.tools.reference.classification import point_in_zone_classifier + + golden_input = _load_golden("point-in-zone-classifier.all-outside.input.json") + golden_output = _load_golden("point-in-zone-classifier.all-outside.output.json") + + ctx = SelectionContext( + type=ContextType.MULTI, + features=golden_input["features"], + ) + result = point_in_zone_classifier(ctx, {}) + + expected_text = golden_output["content"][0]["text"] + expected_feature = json.loads(expected_text) + + actual = result[0] + actual_md = actual["properties"]["pointMetadata"] + expected_md = expected_feature["properties"]["pointMetadata"] + for i, (a, _e) in enumerate(zip(actual_md, expected_md, strict=True)): + assert a["zone"] == "none", f"Point {i} should be 'none'" + assert a["color"] == "#666666", f"Point {i} should be grey" + + assert actual["properties"]["pointColors"] == ["#666666"] * len(actual_md) diff --git a/shared/components/src/FeatureList/FeatureList.css b/shared/components/src/FeatureList/FeatureList.css index 7ab298c4..b28ca93d 100644 --- a/shared/components/src/FeatureList/FeatureList.css +++ b/shared/components/src/FeatureList/FeatureList.css @@ -294,6 +294,36 @@ color: var(--debrief-text-secondary, #888888); } +/* Info icon */ +.debrief-feature-row__info-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 3px; + cursor: pointer; + opacity: 0; + color: var(--debrief-text-muted, #888888); + transition: opacity 0.15s ease, color 0.15s ease, background-color 0.15s ease; +} + +.debrief-feature-row:hover .debrief-feature-row__info-icon, +.debrief-feature-row--selected .debrief-feature-row__info-icon { + opacity: 1; +} + +.debrief-feature-row__info-icon:hover { + color: var(--debrief-text-primary, #e0e0e0); + background-color: rgba(255, 255, 255, 0.1); +} + +[data-theme='light'] .debrief-feature-row__info-icon:hover { + color: var(--debrief-text-primary, #333333); + background-color: rgba(0, 0, 0, 0.08); +} + /* Format icon (Feature 097) */ .debrief-feature-row__format-icon { display: flex; diff --git a/shared/components/src/FeatureList/FeatureRow.tsx b/shared/components/src/FeatureList/FeatureRow.tsx index d089a5bb..a742126b 100644 --- a/shared/components/src/FeatureList/FeatureRow.tsx +++ b/shared/components/src/FeatureList/FeatureRow.tsx @@ -89,6 +89,14 @@ function getFeatureType(feature: DebriefFeature): string { * Get additional info for a feature. */ function getFeatureInfo(feature: DebriefFeature): string | null { + const parts: string[] = []; + + if (feature.id != null) { + const idStr = String(feature.id); + const shortId = idStr.length > 8 ? idStr.slice(0, 8) + '…' : idStr; + parts.push(shortId); + } + if (isTrackFeature(feature)) { let start: string | undefined = feature.properties.start_time; let end: string | undefined = feature.properties.end_time; @@ -111,10 +119,11 @@ function getFeatureInfo(feature: DebriefFeature): string | null { if (start && end) { const startDate = new Date(start); const endDate = new Date(end); - return `${startDate.toLocaleTimeString()} - ${endDate.toLocaleTimeString()}`; + parts.push(`${startDate.toLocaleTimeString()} - ${endDate.toLocaleTimeString()}`); } } - return null; + + return parts.length > 0 ? parts.join(' · ') : null; } /** @@ -271,7 +280,33 @@ export function FeatureRow({ )} - {!isHidden && info && {info}} + {!isHidden && info && feature && ( + { + e.stopPropagation(); + const props = feature.properties as unknown as Record; + const lines = [`id: ${String(feature.id)}`]; + for (const [k, v] of Object.entries(props)) { + if (k === 'style' || k === 'position_style_overrides' || k === 'times' || k === 'pointMetadata' || k === 'pointColors' || k === 'zones') { + lines.push(`${k}: [${Array.isArray(v) ? v.length + ' items' : 'object'}]`); + } else if (v !== null && v !== undefined && typeof v !== 'object') { + lines.push(`${k}: ${String(v)}`); + } else if (v !== null && v !== undefined) { + lines.push(`${k}: ${JSON.stringify(v)}`); + } + } + lines.push(`geometry.type: ${feature.geometry?.type ?? 'null'}`); + window.alert(lines.join('\n')); + }} + > + {info} + + )} + {!isHidden && info && !feature && {info}} ); } diff --git a/shared/components/src/MapView/MapView.tsx b/shared/components/src/MapView/MapView.tsx index 739b810c..e11ab786 100644 --- a/shared/components/src/MapView/MapView.tsx +++ b/shared/components/src/MapView/MapView.tsx @@ -352,6 +352,18 @@ export function MapView({ direction: 'top', }); + // Add popup with feature details including ID + const props = (feature.properties ?? {}) as Record; + const popupLines = [`id: ${String(featureId)}`]; + for (const [k, v] of Object.entries(props)) { + if (k === 'style' || k === 'position_style_overrides' || k === 'times' || k === 'positions' || k === 'pointMetadata' || k === 'pointColors' || k === 'zones') continue; + if (v !== null && v !== undefined && typeof v !== 'object') { + popupLines.push(`${k}: ${String(v)}`); + } + } + popupLines.push(`geometry.type: ${feature.geometry?.type ?? 'null'}`); + layer.bindPopup(popupLines.join('
'), { maxWidth: 400 }); + // Click handler — works for all features including decomposed // MultiPolygon child polygons (which now have IDs like "parent/polygons/0") layer.on('click', (e) => { diff --git a/shared/tools/reference/classification/point-in-zone-classifier.1.0.md b/shared/tools/reference/classification/point-in-zone-classifier.1.0.md new file mode 100644 index 00000000..36e3f40b --- /dev/null +++ b/shared/tools/reference/classification/point-in-zone-classifier.1.0.md @@ -0,0 +1,235 @@ +--- +name: point-in-zone-classifier +version: 1.0 +category: reference/classification +status: draft +--- + +# Point-in-Zone Classifier + +> Classifies reference points by buffer zone membership, updating per-point metadata with zone assignment and color. + +## MCP + +**Description**: Classifies each coordinate in a MultiPoint reference feature by testing containment against concentric detection zone polygons. Updates per-point metadata with zone name and color, enabling visual distinction of points by detection likelihood. + +**When to use**: When an analyst has generated reference points and buffer zones and needs to see which points fall within each detection zone. Typically step 4 of the E03 buffer zone analysis cascade. + +**Parameters**: None — the tool operates on the selected features directly. + +**Returns**: Mutation ToolResponse containing the modified MultiPoint feature with updated `pointMetadata` (zone, color) and a `pointColors` array for per-point rendering. + +## Inputs + +**Schema**: `ContextType.MULTI` — requires exactly two features: +- One feature with `kind: "POINT"` and `locationType: "REFERENCE"` (MultiPoint geometry) +- One feature with `kind: "ZONE"` (MultiPolygon geometry) + +**Constraints**: +- Reference feature must have MultiPoint geometry with at least one coordinate +- Reference feature must have a `pointMetadata` array parallel to coordinates +- Zone feature must have MultiPolygon geometry with at least one polygon +- Zone feature must have a `zones` array with metadata for each polygon + +**Defaults**: None — no parameters. + +## Outputs + +**Response Schema**: `specs/041-document-tool-results/data-model.md#ToolResponse` + +### Result Type Path + +`mutation/reference/classified_points` + +The `result_subtype` is `reference/classified_points`. + +### Annotations + +- `debrief:resultType`: `mutation/reference/classified_points` +- `debrief:sourceFeatures`: `[, ]` +- `debrief:label`: `"Classified {N} points: {n1} in 75%, {n2} in 50%, {n3} in 25%, {n4} outside"` + +## Algorithm + +### Point-in-Polygon (Ray Casting) + +```pseudocode +FUNCTION point_in_polygon(px: float, py: float, ring: list[list[float]]) -> boolean: + // Ray-casting algorithm: count edge crossings of a horizontal ray to the right + inside = false + n = length(ring) + + j = n - 1 + FOR i = 0 TO n - 1: + xi = ring[i][0] + yi = ring[i][1] + xj = ring[j][0] + yj = ring[j][1] + + // Check if ray crosses this edge + IF ((yi > py) != (yj > py)) AND (px < (xj - xi) * (py - yi) / (yj - yi) + xi): + inside = NOT inside + END IF + + j = i + END FOR + + RETURN inside +END FUNCTION +``` + +### Classification + +```pseudocode +FUNCTION classify_points(context: SelectionContext) -> list[Feature]: + // Extract features by kind + ref_feature = null + zone_feature = null + + FOR EACH feature IN context.features: + IF feature.properties.kind = "POINT" AND feature.properties.locationType = "REFERENCE": + ref_feature = feature + ELSE IF feature.properties.kind = "ZONE": + zone_feature = feature + END IF + END FOR + + // Validate inputs + IF ref_feature IS NULL: + RETURN build_error("No reference point feature found", "invalid_input", []) + END IF + IF zone_feature IS NULL: + RETURN build_error("No zone feature found", "invalid_input", []) + END IF + IF ref_feature.geometry.type != "MultiPoint": + RETURN build_error("Reference feature must have MultiPoint geometry", "invalid_input", [ref_feature.id]) + END IF + IF zone_feature.geometry.type != "MultiPolygon": + RETURN build_error("Zone feature must have MultiPolygon geometry", "invalid_input", [zone_feature.id]) + END IF + + coordinates = ref_feature.geometry.coordinates + metadata = ref_feature.properties.pointMetadata + zone_polygons = zone_feature.geometry.coordinates // array of polygon rings + zone_info = zone_feature.properties.zones // array of zone metadata + + IF length(metadata) != length(coordinates): + RETURN build_error("pointMetadata length must match coordinates length", "invalid_input", [ref_feature.id]) + END IF + + // Classify each point + point_colors = empty list + zone_counts = {} // zone name -> count + + FOR i = 0 TO length(coordinates) - 1: + px = coordinates[i][0] // longitude + py = coordinates[i][1] // latitude + + assigned_zone = "none" + assigned_color = "#666666" + + // Test zones innermost first (index 0 = highest likelihood) + FOR z = 0 TO length(zone_polygons) - 1: + ring = zone_polygons[z][0] // outer ring of this polygon + + IF point_in_polygon(px, py, ring): + assigned_zone = zone_info[z].name + assigned_color = zone_info[z].style.fill_color + OR zone_info[z].style.color + BREAK // innermost match wins + END IF + END FOR + + // Update metadata entry (preserve existing fields) + metadata[i].zone = assigned_zone + metadata[i].color = assigned_color + point_colors.append(assigned_color) + + // Track counts + IF assigned_zone NOT IN zone_counts: + zone_counts[assigned_zone] = 0 + END IF + zone_counts[assigned_zone] = zone_counts[assigned_zone] + 1 + END FOR + + // Build classified feature (copy of original with updated metadata) + classified = deep_copy(ref_feature) + classified.properties.pointMetadata = metadata + classified.properties.pointColors = point_colors + + // Build label + label_parts = empty list + FOR EACH zone IN zone_info: + count = zone_counts.get(zone.name, 0) + label_parts.append(count + " in " + zone.name) + END FOR + outside_count = zone_counts.get("none", 0) + label_parts.append(outside_count + " outside") + label = "Classified " + length(coordinates) + " points: " + join(label_parts, ", ") + + RETURN [classified] +END FUNCTION +``` + +## Edge Cases + +| Scenario | Expected Behavior | +|----------|------------------| +| No reference point feature in input | Return error: "No reference point feature found" | +| No zone feature in input | Return error: "No zone feature found" | +| Reference feature is not MultiPoint | Return error: "Reference feature must have MultiPoint geometry" | +| Zone feature is not MultiPolygon | Return error: "Zone feature must have MultiPolygon geometry" | +| Empty coordinates array (0 points) | Return feature unchanged with empty pointMetadata and pointColors arrays | +| pointMetadata length != coordinates length | Return error: "pointMetadata length must match coordinates length" | +| Point exactly on zone boundary | Treated as inside (standard ray-casting boundary inclusion) | +| All points outside all zones | All points get zone="none", color="#666666" | +| All points inside innermost zone | All points get innermost zone assignment | +| Re-classification (metadata already has zone/color) | Existing zone/color fields are overwritten with new values | +| Zone feature has empty zones array | All points classified as "none" | +| Multiple zone features in input | Only the first ZONE feature is used | +| Multiple reference features in input | Only the first POINT/REFERENCE feature is used | + +## Examples + +### Golden Example Files + +- Basic input: `point-in-zone-classifier.basic.input.json` +- Basic output: `point-in-zone-classifier.basic.output.json` +- All-outside input: `point-in-zone-classifier.all-outside.input.json` +- All-outside output: `point-in-zone-classifier.all-outside.output.json` + +### Basic Example + +**Input**: A MultiPoint feature with 6 reference points and a MultiPolygon zone feature with 3 concentric zones. Points are positioned so that 2 fall in the innermost zone, 2 in the middle zone, and 2 outside all zones. + +**Output**: The same MultiPoint feature with updated `pointMetadata` entries (zone and color fields added) and a `pointColors` array for per-point rendering. + +### All-Outside Example + +**Input**: A MultiPoint feature with 4 reference points and a MultiPolygon zone feature, where all points are far from the zones. + +**Output**: All points classified with zone="none" and color="#666666". + +## Changelog + +### 1.0 (2026-02-17) +- Initial release with ray-casting point-in-polygon +- Innermost-first zone priority +- Per-point metadata update (zone, color) +- pointColors array for renderer support + +## References + +**Related Tools**: +- [generate-reference-points](../generation/generate-reference-points.1.0.md) — Produces the MultiPoint input +- [buffer-zone-generator](../../sensor/detection/buffer-zone-generator.1.0.md) — Produces the zone MultiPolygon input +- Zone Histogram Generator (#082) — Downstream consumer; counts classified points per zone + +**Schemas**: +- [ReferenceLocation](../../../schemas/src/linkml/geojson.yaml) — GeoJSON Feature schema for reference points +- [PointMetadataEntry](../../../schemas/src/linkml/geojson.yaml) — Per-point metadata within MultiPoint +- [FeatureKindEnum](../../../schemas/src/linkml/common.yaml) — Feature type discriminator (POINT, ZONE) + +**External**: +- [GeoJSON RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946) — GeoJSON specification +- [Ray Casting Algorithm](https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm) — Point-in-polygon test diff --git a/shared/tools/reference/classification/point-in-zone-classifier.all-outside.input.json b/shared/tools/reference/classification/point-in-zone-classifier.all-outside.input.json new file mode 100644 index 00000000..226f0301 --- /dev/null +++ b/shared/tools/reference/classification/point-in-zone-classifier.all-outside.input.json @@ -0,0 +1,139 @@ +{ + "features": [ + { + "type": "Feature", + "id": "ref-scatter", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [10.0, 60.0], + [11.0, 61.0], + [12.0, 62.0], + [13.0, 63.0] + ] + }, + "properties": { + "kind": "POINT", + "locationType": "REFERENCE", + "name": "Reference Points (scatter 4)", + "style": { + "shape": "square", + "color": "#666666", + "radius": 5 + }, + "pointMetadata": [ + {"index": 0, "name": "Ref 1"}, + {"index": 1, "name": "Ref 2"}, + {"index": 2, "name": "Ref 3"}, + {"index": 3, "name": "Ref 4"} + ] + } + }, + { + "type": "Feature", + "id": "zone-002", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-4.55, 50.15], + [-4.45, 50.15], + [-4.25, 50.2], + [-4.25, 50.3], + [-4.45, 50.35], + [-4.55, 50.35], + [-4.6, 50.25], + [-4.55, 50.15] + ] + ], + [ + [ + [-4.65, 50.1], + [-4.4, 50.1], + [-4.15, 50.15], + [-4.15, 50.35], + [-4.4, 50.4], + [-4.65, 50.4], + [-4.7, 50.25], + [-4.65, 50.1] + ] + ], + [ + [ + [-4.8, 50.0], + [-4.3, 50.0], + [-4.0, 50.1], + [-4.0, 50.4], + [-4.3, 50.5], + [-4.8, 50.5], + [-4.9, 50.25], + [-4.8, 50.0] + ] + ] + ] + }, + "properties": { + "kind": "ZONE", + "name": "Detection Zones (75%, 50%, 25%)", + "style": { + "fill": true, + "fill_color": "#9C27B0", + "fill_opacity": 0.25, + "stroke": true, + "color": "#9C27B0", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + }, + "zones": [ + { + "name": "75%", + "detection_likelihood_pct": 75, + "buffer_distance_nm": 3.0, + "style": { + "fill": true, + "fill_color": "#9C27B0", + "fill_opacity": 0.25, + "stroke": true, + "color": "#9C27B0", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + } + }, + { + "name": "50%", + "detection_likelihood_pct": 50, + "buffer_distance_nm": 6.0, + "style": { + "fill": true, + "fill_color": "#F44336", + "fill_opacity": 0.18, + "stroke": true, + "color": "#F44336", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + } + }, + { + "name": "25%", + "detection_likelihood_pct": 25, + "buffer_distance_nm": 12.0, + "style": { + "fill": true, + "fill_color": "#FF9800", + "fill_opacity": 0.12, + "stroke": true, + "color": "#FF9800", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + } + } + ] + } + } + ] +} diff --git a/shared/tools/reference/classification/point-in-zone-classifier.all-outside.output.json b/shared/tools/reference/classification/point-in-zone-classifier.all-outside.output.json new file mode 100644 index 00000000..173c8032 --- /dev/null +++ b/shared/tools/reference/classification/point-in-zone-classifier.all-outside.output.json @@ -0,0 +1,15 @@ +{ + "content": [ + { + "type": "resource", + "uri": "feature://ref-scatter", + "mimeType": "application/geo+json", + "text": "{\"type\":\"Feature\",\"id\":\"ref-scatter\",\"geometry\":{\"type\":\"MultiPoint\",\"coordinates\":[[10.0,60.0],[11.0,61.0],[12.0,62.0],[13.0,63.0]]},\"properties\":{\"kind\":\"POINT\",\"locationType\":\"REFERENCE\",\"name\":\"Reference Points (scatter 4)\",\"style\":{\"shape\":\"square\",\"color\":\"#666666\",\"radius\":5},\"pointMetadata\":[{\"index\":0,\"name\":\"Ref 1\",\"zone\":\"none\",\"color\":\"#666666\"},{\"index\":1,\"name\":\"Ref 2\",\"zone\":\"none\",\"color\":\"#666666\"},{\"index\":2,\"name\":\"Ref 3\",\"zone\":\"none\",\"color\":\"#666666\"},{\"index\":3,\"name\":\"Ref 4\",\"zone\":\"none\",\"color\":\"#666666\"}],\"pointColors\":[\"#666666\",\"#666666\",\"#666666\",\"#666666\"]}}", + "annotations": { + "debrief:resultType": "mutation/reference/classified_points", + "debrief:sourceFeatures": ["ref-scatter", "zone-002"], + "debrief:label": "Classified 4 points: 0 in 75%, 0 in 50%, 0 in 25%, 4 outside" + } + } + ] +} diff --git a/shared/tools/reference/classification/point-in-zone-classifier.basic.input.json b/shared/tools/reference/classification/point-in-zone-classifier.basic.input.json new file mode 100644 index 00000000..2908d74d --- /dev/null +++ b/shared/tools/reference/classification/point-in-zone-classifier.basic.input.json @@ -0,0 +1,143 @@ +{ + "features": [ + { + "type": "Feature", + "id": "ref-grid", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [-4.5, 50.2], + [-4.45, 50.22], + [-4.35, 50.28], + [-4.3, 50.32], + [-3.8, 50.5], + [-5.5, 49.0] + ] + }, + "properties": { + "kind": "POINT", + "locationType": "REFERENCE", + "name": "Reference Points (grid 6)", + "style": { + "shape": "square", + "color": "#666666", + "radius": 5 + }, + "pointMetadata": [ + {"index": 0, "name": "Ref 1"}, + {"index": 1, "name": "Ref 2"}, + {"index": 2, "name": "Ref 3"}, + {"index": 3, "name": "Ref 4"}, + {"index": 4, "name": "Ref 5"}, + {"index": 5, "name": "Ref 6"} + ] + } + }, + { + "type": "Feature", + "id": "zone-001", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-4.55, 50.15], + [-4.45, 50.15], + [-4.25, 50.2], + [-4.25, 50.3], + [-4.45, 50.35], + [-4.55, 50.35], + [-4.6, 50.25], + [-4.55, 50.15] + ] + ], + [ + [ + [-4.65, 50.1], + [-4.4, 50.1], + [-4.15, 50.15], + [-4.15, 50.35], + [-4.4, 50.4], + [-4.65, 50.4], + [-4.7, 50.25], + [-4.65, 50.1] + ] + ], + [ + [ + [-4.8, 50.0], + [-4.3, 50.0], + [-4.0, 50.1], + [-4.0, 50.4], + [-4.3, 50.5], + [-4.8, 50.5], + [-4.9, 50.25], + [-4.8, 50.0] + ] + ] + ] + }, + "properties": { + "kind": "ZONE", + "name": "Detection Zones (75%, 50%, 25%)", + "style": { + "fill": true, + "fill_color": "#9C27B0", + "fill_opacity": 0.25, + "stroke": true, + "color": "#9C27B0", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + }, + "zones": [ + { + "name": "75%", + "detection_likelihood_pct": 75, + "buffer_distance_nm": 3.0, + "style": { + "fill": true, + "fill_color": "#9C27B0", + "fill_opacity": 0.25, + "stroke": true, + "color": "#9C27B0", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + } + }, + { + "name": "50%", + "detection_likelihood_pct": 50, + "buffer_distance_nm": 6.0, + "style": { + "fill": true, + "fill_color": "#F44336", + "fill_opacity": 0.18, + "stroke": true, + "color": "#F44336", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + } + }, + { + "name": "25%", + "detection_likelihood_pct": 25, + "buffer_distance_nm": 12.0, + "style": { + "fill": true, + "fill_color": "#FF9800", + "fill_opacity": 0.12, + "stroke": true, + "color": "#FF9800", + "weight": 2, + "opacity": 1.0, + "dash_array": "6, 4" + } + } + ] + } + } + ] +} diff --git a/shared/tools/reference/classification/point-in-zone-classifier.basic.output.json b/shared/tools/reference/classification/point-in-zone-classifier.basic.output.json new file mode 100644 index 00000000..b5bbf918 --- /dev/null +++ b/shared/tools/reference/classification/point-in-zone-classifier.basic.output.json @@ -0,0 +1,15 @@ +{ + "content": [ + { + "type": "resource", + "uri": "feature://ref-grid", + "mimeType": "application/geo+json", + "text": "{\"type\":\"Feature\",\"id\":\"ref-grid\",\"geometry\":{\"type\":\"MultiPoint\",\"coordinates\":[[-4.5,50.2],[-4.45,50.22],[-4.35,50.28],[-4.3,50.32],[-3.8,50.5],[-5.5,49.0]]},\"properties\":{\"kind\":\"POINT\",\"locationType\":\"REFERENCE\",\"name\":\"Reference Points (grid 6)\",\"style\":{\"shape\":\"square\",\"color\":\"#666666\",\"radius\":5},\"pointMetadata\":[{\"index\":0,\"name\":\"Ref 1\",\"zone\":\"75%\",\"color\":\"#9C27B0\"},{\"index\":1,\"name\":\"Ref 2\",\"zone\":\"75%\",\"color\":\"#9C27B0\"},{\"index\":2,\"name\":\"Ref 3\",\"zone\":\"75%\",\"color\":\"#9C27B0\"},{\"index\":3,\"name\":\"Ref 4\",\"zone\":\"50%\",\"color\":\"#F44336\"},{\"index\":4,\"name\":\"Ref 5\",\"zone\":\"none\",\"color\":\"#666666\"},{\"index\":5,\"name\":\"Ref 6\",\"zone\":\"none\",\"color\":\"#666666\"}],\"pointColors\":[\"#9C27B0\",\"#9C27B0\",\"#9C27B0\",\"#F44336\",\"#666666\",\"#666666\"]}}", + "annotations": { + "debrief:resultType": "mutation/reference/classified_points", + "debrief:sourceFeatures": ["ref-grid", "zone-001"], + "debrief:label": "Classified 6 points: 3 in 75%, 1 in 50%, 0 in 25%, 2 outside" + } + } + ] +} diff --git a/specs/081-point-in-zone-classifier/contracts/tool-contract.yaml b/specs/081-point-in-zone-classifier/contracts/tool-contract.yaml new file mode 100644 index 00000000..29776b48 --- /dev/null +++ b/specs/081-point-in-zone-classifier/contracts/tool-contract.yaml @@ -0,0 +1,62 @@ +# Tool Contract: point-in-zone-classifier +# Defines the MCP-compatible tool interface for both Python and TypeScript implementations. + +name: point-in-zone-classifier +version: 1.0.0 +category: reference/classification +status: draft + +context: + type: MULTI + input_kinds: + - POINT + - ZONE + description: > + Requires exactly two features: one MultiPoint reference feature (kind=POINT, + locationType=REFERENCE) and one MultiPolygon zone feature (kind=ZONE). + +parameters: {} + # No parameters — the tool operates on selected features directly. + +output: + kind: mutation/reference/classified_points + description: > + Returns the reference MultiPoint feature with updated pointMetadata + (zone and color fields added/updated) and a new pointColors array + parallel to coordinates. + +annotations: + debrief:resultType: mutation/reference/classified_points + debrief:sourceFeatures: "[, ]" + debrief:label: "Classified {N} points: {n1} in {zone1}, {n2} in {zone2}, ... {nK} outside" + +errors: + - code: invalid_input + condition: No reference point feature found + message: "No reference point feature found in selection" + - code: invalid_input + condition: No zone feature found + message: "No zone feature found in selection" + - code: invalid_input + condition: Reference feature geometry is not MultiPoint + message: "Reference feature must have MultiPoint geometry" + - code: invalid_input + condition: Zone feature geometry is not MultiPolygon + message: "Zone feature must have MultiPolygon geometry" + - code: invalid_input + condition: pointMetadata array length mismatch + message: "pointMetadata length must match coordinates length" + +registration: + python: + module: debrief_calc.tools.reference.classification + function: point_in_zone_classifier + decorator: "@tool(name='point-in-zone-classifier', ...)" + typescript: + module: tools/reference/classification/pointInZoneClassifier.ts + exports: + - toolDefinition + - execute + web_shell: + registry: apps/web-shell/src/services/toolService.ts + entry: "[pointInZoneClassifierDef.name, { definition, execute }]" diff --git a/specs/081-point-in-zone-classifier/data-model.md b/specs/081-point-in-zone-classifier/data-model.md new file mode 100644 index 00000000..592f5872 --- /dev/null +++ b/specs/081-point-in-zone-classifier/data-model.md @@ -0,0 +1,134 @@ +# Data Model: Point-in-Zone Classifier + +**Feature**: 081-point-in-zone-classifier +**Date**: 2026-02-17 + +## Entities + +### Input: Reference Point Feature (MultiPoint) + +The classifier reads this feature produced by generate-reference-points (#078). + +``` +ReferencePointFeature +├── type: "Feature" +├── id: string # e.g., "ref-grid" +├── geometry +│ ├── type: "MultiPoint" +│ └── coordinates: [lon, lat][] # Array of 2D coordinate pairs +└── properties + ├── kind: "POINT" + ├── locationType: "REFERENCE" + ├── name: string # e.g., "Reference Points (grid 12)" + ├── style + │ ├── shape: "square" + │ ├── color: "#666666" + │ └── radius: 5 + └── pointMetadata: PointMetadataEntry[] # Parallel to coordinates +``` + +### Input: Zone Feature (MultiPolygon) + +The classifier reads this feature produced by buffer-zone-generator (#080). + +``` +ZoneFeature +├── type: "Feature" +├── id: string # e.g., "zone-" +├── geometry +│ ├── type: "MultiPolygon" +│ └── coordinates: [ring][] # Array of polygon rings (outer ring only per zone) +└── properties + ├── kind: "ZONE" + ├── name: string # e.g., "Detection Zones (75%, 50%, 25%)" + ├── style: ZoneStyle # Feature-level style (innermost zone) + └── zones: ZoneMetadata[] # Per-zone metadata, ordered innermost → outermost +``` + +### ZoneMetadata + +Per-zone metadata entry from the buffer-zone-generator. + +| Field | Type | Description | Example | +|-------|------|-------------|---------| +| name | string | Zone label | "75%" | +| detection_likelihood_pct | integer | Detection probability (1-100) | 75 | +| buffer_distance_nm | float | Distance from track in nm | 3.0 | +| style | ZoneStyle | Per-zone rendering style | `{fill_color: "#9C27B0", ...}` | + +### ZoneStyle + +``` +ZoneStyle +├── fill: boolean +├── fill_color: string # Hex color, e.g., "#9C27B0" +├── fill_opacity: float +├── stroke: boolean +├── color: string # Stroke color +├── weight: number +├── opacity: float +└── dash_array: string +``` + +### PointMetadataEntry (Extended) + +The classifier extends existing `PointMetadataEntry` with `zone` and `color` fields. + +| Field | Type | Required | Source | Description | +|-------|------|----------|--------|-------------| +| index | integer | yes | generate-reference-points | 0-based ordinal | +| name | string | yes | generate-reference-points | Human-readable label | +| zone | string | added by classifier | point-in-zone-classifier | Zone name or "none" | +| color | string | added by classifier | point-in-zone-classifier | Hex color for per-point rendering | + +### Output: Classified Reference Feature + +The classifier outputs a mutated copy of the input reference feature with: +1. Updated `pointMetadata` entries (zone + color added) +2. New `pointColors` array (parallel to coordinates) + +``` +ClassifiedReferenceFeature +├── type: "Feature" +├── id: string # Same as input +├── geometry +│ ├── type: "MultiPoint" +│ └── coordinates: [lon, lat][] # Same as input (unchanged) +└── properties + ├── kind: "POINT" + ├── locationType: "REFERENCE" + ├── name: string # Same as input + ├── style + │ ├── shape: "square" + │ ├── color: "#666666" # Default color (unchanged) + │ └── radius: 5 + ├── pointMetadata: ExtendedPointMetadataEntry[] # Updated with zone/color + └── pointColors: string[] # NEW: Hex colors parallel to coordinates +``` + +## State Transitions + +The reference point feature transitions through the E03 pipeline: + +``` +[Generate Reference Points #078] + → ReferencePointFeature (pointMetadata with index, name) + → [Point-in-Zone Classifier #081] + → ClassifiedReferenceFeature (pointMetadata + zone, color; pointColors added) + → [Zone Histogram Generator #082] + → ZoneHistogramDataset (counts per zone from classified metadata) +``` + +## Validation Rules + +| Rule | Scope | Description | +|------|-------|-------------| +| V-001 | Input | Reference feature must have `kind: "POINT"` and `locationType: "REFERENCE"` | +| V-002 | Input | Reference feature must have MultiPoint geometry | +| V-003 | Input | Zone feature must have `kind: "ZONE"` | +| V-004 | Input | Zone feature must have MultiPolygon geometry | +| V-005 | Input | `pointMetadata.length` must equal `coordinates.length` | +| V-006 | Input | Zone feature must have non-empty `zones` array | +| V-007 | Output | `pointColors.length` must equal `coordinates.length` | +| V-008 | Output | Every pointMetadata entry must have `zone` and `color` fields | +| V-009 | Output | Geometry coordinates must be unchanged from input | diff --git a/specs/081-point-in-zone-classifier/evidence/pr-body.md b/specs/081-point-in-zone-classifier/evidence/pr-body.md new file mode 100644 index 00000000..5a85bb44 --- /dev/null +++ b/specs/081-point-in-zone-classifier/evidence/pr-body.md @@ -0,0 +1,102 @@ +## Summary + +- Implement point-in-zone classifier tool — step 4 of the E03 buffer zone analysis chain +- Classifies reference points by testing each coordinate against concentric detection zone polygons using ray-casting algorithm (innermost-first priority) +- Updates per-point metadata with zone name and color, adds `pointColors` array for per-point rendering +- Dual implementation in Python (debrief-calc) and TypeScript (VS Code + web-shell) with identical algorithm + +## Changes + +### Phase 1: Specification & Setup +- Feature spec at `specs/081-point-in-zone-classifier/spec.md` (3 user stories, 14 functional requirements) +- Tool spec at `shared/tools/reference/classification/point-in-zone-classifier.1.0.md` (all 9 required sections) +- 2 golden I/O example pairs (basic classification + all-outside) + +### Phase 2: Implementation Plan +- Research document resolving algorithm, context type, result type, color mapping, and dependency questions +- Data model, tool contract, and quickstart documentation +- Constitution check: all articles pass + +### Phase 3: Python Implementation +- `services/calc/debrief_calc/tools/reference/classification.py` — ray-casting `_point_in_polygon` + `point_in_zone_classifier` registered via `@tool` decorator with `ContextType.MULTI` +- Registered in `reference/__init__.py` and `tools/__init__.py` + +### Phase 4: Python Tests (22 tests, all passing) +- `TestClassifyBasic` (7 tests) — zone assignment, pointColors, innermost-first priority +- `TestMetadataPreservation` (4 tests) — field preservation, reclassification, no mutation +- `TestDeterminism` (2 tests) — identical output, geometry unchanged +- `TestEdgeCases` (7 tests) — missing features, wrong geometry types, metadata mismatch, empty inputs +- `TestGoldenExamples` (2 tests) — validated against golden I/O files + +### Phase 5: TypeScript Implementation +- `apps/vscode/src/tools/reference/classification/pointInZoneClassifier.ts` — identical ray-casting algorithm +- Barrel export at `apps/vscode/src/tools/reference/classification/index.ts` +- Registered in `apps/web-shell/src/services/toolService.ts` + +## Evidence + +### Test Results + +| Metric | Value | +|--------|-------| +| Total Tests | 22 | +| Passed | 22 | +| Failed | 0 | + +| Test Class | Tests | Status | +|-----------|-------|--------| +| TestClassifyBasic | 7 | All pass | +| TestMetadataPreservation | 4 | All pass | +| TestDeterminism | 2 | All pass | +| TestEdgeCases | 7 | All pass | +| TestGoldenExamples | 2 | All pass | + +### Golden Example Validation + +- `point-in-zone-classifier.basic` — PASS (6 points: 3 in 75%, 1 in 50%, 2 outside) +- `point-in-zone-classifier.all-outside` — PASS (4 points: all outside) + +### Usage Example + +```python +from debrief_calc.tools.reference.classification import point_in_zone_classifier + +result = point_in_zone_classifier(context, {}) +classified = result[0] + +for md in classified["properties"]["pointMetadata"]: + print(f"{md['name']}: zone={md['zone']}, color={md['color']}") +# Ref 1: zone=75%, color=#9C27B0 +# Ref 4: zone=50%, color=#F44336 +# Ref 5: zone=none, color=#666666 + +print(classified["properties"]["pointColors"]) +# ["#9C27B0", "#9C27B0", "#9C27B0", "#F44336", "#666666", "#666666"] +``` + +### E03 Pipeline Position + +``` +Step 1: generate-reference-points → MultiPoint (POINT/REFERENCE) +Step 2: (move track) → Track feature updated +Step 3: buffer-zone-generator → MultiPolygon (ZONE) +Step 4: point-in-zone-classifier → MultiPoint (classified, with pointColors) ← THIS +Step 5: zone-histogram-generator → Histogram (counts per zone) +``` + +## Test Plan + +- [x] 22 Python unit tests covering all 3 user stories + edge cases +- [x] 2 golden example validation tests (basic + all-outside) +- [x] TypeScript compilation passes with no errors +- [x] Identical ray-casting algorithm in both languages +- [x] Metadata preservation verified (no mutation of input) +- [x] Determinism verified (same inputs → same output) + +## Related + +- Spec: `specs/081-point-in-zone-classifier/spec.md` +- Tasks: `specs/081-point-in-zone-classifier/tasks.md` +- Tool spec: `shared/tools/reference/classification/point-in-zone-classifier.1.0.md` +- Dependencies: #078 (generate-reference-points), #080 (buffer-zone-generator) +- Downstream: #082 (zone-histogram-generator) diff --git a/specs/081-point-in-zone-classifier/evidence/test-summary.md b/specs/081-point-in-zone-classifier/evidence/test-summary.md new file mode 100644 index 00000000..de57ed66 --- /dev/null +++ b/specs/081-point-in-zone-classifier/evidence/test-summary.md @@ -0,0 +1,47 @@ +# Test Summary: Point-in-Zone Classifier + +**Date**: 2026-02-17 +**Branch**: `claude/speckit-start-081-3Btda` + +## Python Tests + +**Command**: `uv run --extra dev pytest tests/tools/reference/test_classification.py -v` +**Result**: 22 passed, 0 failed + +### Test Breakdown + +| Test Class | Tests | Status | +|-----------|-------|--------| +| TestClassifyBasic | 7 | All pass | +| TestMetadataPreservation | 4 | All pass | +| TestDeterminism | 2 | All pass | +| TestEdgeCases | 7 | All pass | +| TestGoldenExamples | 2 | All pass | + +### Coverage by User Story + +| User Story | Tests | Coverage | +|-----------|-------|---------| +| US1: Classify by zone | test_point_inside_inner_zone, test_point_in_middle_zone, test_point_in_outer_zone, test_point_outside_all_zones, test_multiple_points_classified_correctly, test_point_colors_array_parallel, test_innermost_zone_wins | Full | +| US2: Preserve metadata | test_preserves_index_and_name, test_preserves_custom_fields, test_reclassification_updates_zone_color, test_does_not_mutate_input | Full | +| US3: Determinism | test_identical_inputs_produce_identical_output, test_geometry_unchanged | Full | + +### Edge Cases Covered + +- No reference feature +- No zone feature +- Non-MultiPoint reference geometry +- Non-MultiPolygon zone geometry +- Metadata length mismatch +- Empty coordinates +- Empty zones array + +### Golden Example Validation + +- `point-in-zone-classifier.basic` — PASS (6 points: 3 in 75%, 1 in 50%, 2 outside) +- `point-in-zone-classifier.all-outside` — PASS (4 points: all outside) + +## TypeScript + +**Compilation**: `npx tsc --noEmit --project tsconfig.json` — No errors in classification files +**Implementation**: Identical algorithm (ray-casting, innermost-first priority) diff --git a/specs/081-point-in-zone-classifier/evidence/usage-example.md b/specs/081-point-in-zone-classifier/evidence/usage-example.md new file mode 100644 index 00000000..e27e85b5 --- /dev/null +++ b/specs/081-point-in-zone-classifier/evidence/usage-example.md @@ -0,0 +1,65 @@ +# Usage Example: Point-in-Zone Classifier + +## Python + +```python +from debrief_calc.models import ContextType, SelectionContext +from debrief_calc.tools.reference.classification import point_in_zone_classifier + +# ref_feature from generate-reference-points (#078) +# zone_feature from buffer-zone-generator (#080) + +context = SelectionContext( + type=ContextType.MULTI, + features=[ref_feature, zone_feature], +) + +result = point_in_zone_classifier(context, {}) +classified = result[0] + +# Per-point classification +for md in classified["properties"]["pointMetadata"]: + print(f"{md['name']}: zone={md['zone']}, color={md['color']}") +# Output: +# Ref 1: zone=75%, color=#9C27B0 +# Ref 2: zone=75%, color=#9C27B0 +# Ref 3: zone=75%, color=#9C27B0 +# Ref 4: zone=50%, color=#F44336 +# Ref 5: zone=none, color=#666666 +# Ref 6: zone=none, color=#666666 + +# Per-point colors for renderer +print(classified["properties"]["pointColors"]) +# ["#9C27B0", "#9C27B0", "#9C27B0", "#F44336", "#666666", "#666666"] +``` + +## TypeScript + +```typescript +import { execute } from './tools/reference/classification/pointInZoneClassifier'; + +const result = execute([refFeature, zoneFeature], {}); +const classified = result[0]; + +// Per-point classification +for (const md of classified.properties.pointMetadata) { + console.log(`${md.name}: zone=${md.zone}, color=${md.color}`); +} + +// Per-point colors for renderer +console.log(classified.properties.pointColors); +``` + +## E03 Pipeline Integration + +The classifier fits into the E03 buffer zone analysis chain: + +``` +Step 1: generate-reference-points → MultiPoint (POINT/REFERENCE) +Step 2: (move track) → Track feature updated +Step 3: buffer-zone-generator → MultiPolygon (ZONE) +Step 4: point-in-zone-classifier → MultiPoint (classified, with pointColors) +Step 5: zone-histogram-generator → Histogram (counts per zone) +``` + +When the track moves (step 2), the PROV cascade reruns steps 3-5 automatically. diff --git a/specs/081-point-in-zone-classifier/media/linkedin-planning.md b/specs/081-point-in-zone-classifier/media/linkedin-planning.md new file mode 100644 index 00000000..40a8f283 --- /dev/null +++ b/specs/081-point-in-zone-classifier/media/linkedin-planning.md @@ -0,0 +1,11 @@ +Step 4 of 5: classifying 600 reference points by detection zone using ray-casting geometry and zero external dependencies. + +The point-in-zone classifier is the pivot step in the E03 buffer zone analysis chain for Debrief. It takes a grid of reference points and a set of concentric detection probability zones, tests each point against the polygons innermost-first, and writes zone membership and a colour back onto each point. Purple for 75% detection likelihood, red for 50%, orange for 25%, grey for outside everything. + +Two decisions worth sharing. First: no Shapely, no turf.js. The ray-casting even-odd algorithm is around 20 lines in both Python and TypeScript, runs offline, and handles concave polygons correctly. The extra dependency isn't worth it. Second: colours come from the zone feature itself, not a hardcoded map. If zones get regenerated with different styling, the classifier picks it up automatically — no configuration drift. + +This tool sits in the middle of a reactive PROV cascade. When a track moves, zones update, points reclassify, and the histogram at the end of the chain reflects new detection probability distribution. All deterministic, all offline. + +Planning post with the full decision log: [link] + +#maritimeanalysis #geospatial #defencetechnology diff --git a/specs/081-point-in-zone-classifier/media/linkedin-shipped.md b/specs/081-point-in-zone-classifier/media/linkedin-shipped.md new file mode 100644 index 00000000..538e75a5 --- /dev/null +++ b/specs/081-point-in-zone-classifier/media/linkedin-shipped.md @@ -0,0 +1,13 @@ +Shipped the point-in-zone classifier for Debrief — 22 tests passing, zero external dependencies. + +The tool classifies a grid of reference coordinates against concentric detection probability zones. Each point gets tested against the innermost zone first; first match wins. Purple for 75% likelihood, red for 50%, orange for 25%, grey for outside. The output is a flat `pointColors` array parallel to the coordinates — the renderer draws each point without any additional lookup. + +The whole point-in-polygon algorithm is around 20 lines in both Python and TypeScript. No Shapely, no turf.js. Ray-casting from stdlib handles the geometry correctly for the convex-hull zone shapes this project produces, and it runs entirely offline. + +One thing I didn't expect: hand-placing polygon vertices and assuming a point is "inside the middle zone" is not the same as the algorithm agreeing with you. I had to verify the golden test fixtures against actual ray-casting output rather than geometric intuition. Always run it first. + +This is step 4 of 5 in the E03 buffer zone analysis chain. One tool left — the zone histogram — before the full reactive cascade can run end-to-end. + +Full post: [link] + +#maritimeanalysis #geospatial #defencetechnology diff --git a/specs/081-point-in-zone-classifier/media/planning-post.md b/specs/081-point-in-zone-classifier/media/planning-post.md new file mode 100644 index 00000000..d007855f --- /dev/null +++ b/specs/081-point-in-zone-classifier/media/planning-post.md @@ -0,0 +1,50 @@ +--- +layout: future-post +title: "Planning: Point-in-Zone Classifier" +date: 2026-02-17 +track: [momentum] +author: Ian +reading_time: 4 +tags: [tracer-bullet, buffer-zone-analysis, e03-demo] +excerpt: "Step 4 of the E03 chain: classify reference points by detection zone and recolor them — pure geometry, no external dependencies." +--- + +## What We're Building + +The point-in-zone classifier is step 4 of the E03 buffer zone analysis chain. It takes two features — a grid of reference points (MultiPoint, from step 1) and a set of concentric detection zone polygons (MultiPolygon, from step 3) — and assigns each point to the zone it falls within. Points that fall inside the 75% detection zone come out purple. The 50% zone gets red, the 25% zone orange, and anything outside all zones turns grey. The classified points feed directly into the histogram in step 5. + +The classifier writes two things back onto the reference feature: a `zone` and `color` field per point in the existing `pointMetadata` array, and a new `pointColors` array that runs parallel to the MultiPoint coordinates. The renderer uses `pointColors[i]` to colour the i-th point — no zone lookups, no indirection. + +This is a mutation-type result: it modifies the existing reference point feature rather than creating a new one. The STAC item for the reference point set is updated in place, and provenance records the zone feature that drove the reclassification. + +## How It Fits + +The E03 chain is built to demonstrate reactive PROV cascading: move the track in step 2, and every downstream tool in the chain reruns automatically, producing a new histogram from new zone geometry and newly classified points. + +The classifier sits in the middle of that cascade. It consumes the output of the buffer-zone-generator (#080), which itself reruns when the track moves. When zones change — different radii, different centre positions — the classifier sees new polygons and the point assignments change accordingly. The histogram (#082) then sees a different colour distribution. + +For the E03 demo to make its point about cascade reactivity, this tool has to be stateless and deterministic. Same inputs must always produce identical outputs. That means no mutable state, no timestamps in the algorithm, and no randomness. The provenance system handles the "when did this run" question; the tool itself is a pure function. + +The classifier also has to preserve whatever metadata the generate-reference-points tool (#078) put on each point — index, name, and anything else. Only `zone` and `color` are added or overwritten. Downstream tools can rely on all upstream fields surviving the classification step. + +## Key Decisions + +**Ray-casting for point-in-polygon.** The even-odd ray-casting algorithm casts a horizontal ray rightward from each point and counts polygon edge crossings. Odd count means inside, even means outside. It handles concave polygons correctly, which matters because buffer zone polygons from the buffer-zone-generator are convex hulls rather than perfect circles. The algorithm is also language-neutral — the same logic runs in both the Python service and the TypeScript frontend implementations with no shared code needed. + +I considered the winding number algorithm, which is more robust for self-intersecting polygons, but buffer zones don't self-intersect. I also looked at Shapely (Python) and turf.js (TypeScript) — both would have made this trivial to implement — but they're external dependencies, and the constitution requires offline-first operation with minimal external dependencies. Ray-casting in stdlib is around 20 lines; it's not worth a library for that. + +**Innermost zone wins.** Zones in the MultiPolygon are ordered innermost (index 0, highest detection likelihood) to outermost. For each point, the classifier tests zone 0 first. If the point is inside, it stops there. This means a point that is geometrically inside both the 75% zone and the 50% zone (because the 50% zone contains the 75% zone) gets assigned to 75%. The first match is always the most specific. + +**Colours come from the zone feature, not from a hardcoded map.** The buffer-zone-generator stores per-zone styling in its output, including fill colour. The classifier reads `zones[i].style.fill_color` from the zone feature rather than maintaining its own colour table. If someone regenerates zones with different colours, the classifier picks them up automatically. There's no configuration needed and no divergence between what the polygons look like on the map and what the points get coloured. + +**Mutation result type, not addition.** The tool returns a `mutation` response rather than an `addition`. This reflects what actually happens: the reference point feature already exists in the STAC catalog (put there by generate-reference-points), and the classifier updates it. Creating a new feature would mean two versions of the point set in the catalog, with the old one becoming stale. Mutation keeps the catalog clean and makes the provenance lineage straightforward — one item, updated by one tool. + +**`pointColors` array, parallel to coordinates.** Rather than embedding colour in the zone lookup metadata or using a class-based approach, the classifier writes a flat `pointColors` array directly onto the feature. `pointColors[i]` is the hex colour for the i-th coordinate. The renderer can iterate once and draw each point in its assigned colour with no additional lookups. It mirrors the existing `pointMetadata` parallel array pattern, so the data structure stays consistent across the tool chain. + +## What We'd Love Feedback On + +The antimeridian case is handled by documentation rather than special-casing. The ray-casting algorithm works correctly as long as the point and polygon are in the same coordinate space, and the buffer-zone-generator already accounts for antimeridian wrapping in its polygon output. But I haven't tested a real track that crosses the antimeridian, so I'm curious whether that assumption holds in practice for maritime analysis scenarios. + +The other open question is the bounding box pre-filter. For the expected point counts — 25 to 625 reference points, 3 zones — pure ray-casting completes well under the 1-second target. But some use cases might push toward 10,000 points. Adding a bbox check before the full polygon test would cut the work substantially for points far outside all zones. I left it out for simplicity, but it's easy to add if profiling shows it's needed. + +If you work in maritime SIGINT or surface picture analysis and have thoughts on how detection likelihood zones actually get used in practice — whether the concentric ring model maps to your workflow, or whether you'd want something different from the classification output — I'd be interested to hear it. diff --git a/specs/081-point-in-zone-classifier/media/shipped-post.md b/specs/081-point-in-zone-classifier/media/shipped-post.md new file mode 100644 index 00000000..6bd61a92 --- /dev/null +++ b/specs/081-point-in-zone-classifier/media/shipped-post.md @@ -0,0 +1,32 @@ +--- +layout: future-post +title: "Shipped: Point-in-Zone Classifier" +date: 2026-02-17 +track: [credibility] +author: Ian +reading_time: 4 +tags: [tracer-bullet, buffer-zone-analysis, e03-demo] +excerpt: "Step 4 of E03 is done: 22 tests passing, zero external dependencies, ray-casting in ~20 lines." +--- + +## What We Built + +The point-in-zone classifier is step 4 of the E03 buffer zone analysis chain. It takes a MultiPoint grid of reference coordinates (from generate-reference-points, step 1) and a set of concentric detection zone polygons (from buffer-zone-generator, step 3), tests each point against the polygons innermost-first, and writes zone membership and colour back onto the feature. Purple for 75% detection likelihood, red for 50%, orange for 25%, grey for outside everything. + +The output is a `pointColors` array running parallel to the MultiPoint coordinates. `pointColors[i]` is the hex colour for the i-th point. The renderer iterates once and draws each point; no zone lookups at render time, no indirection. + +Implementation is in both Python (registered via `@tool` in `services/calc`) and TypeScript (web-shell map and VS Code barrel). Same algorithm in both. 22 Python tests pass across five test classes: basic classification, metadata preservation, determinism, edge cases, and golden example validation. + +Two golden I/O pairs are checked in: the basic classification example (6 points: 3 in the 75% zone, 1 in the 50% zone, 2 outside) and an all-outside example (4 points, all grey). These double as regression fixtures — if the algorithm changes and the golden output doesn't update, the tests fail. + +## Lessons Learned + +**Hand-authored polygon vertices don't always contain the points you think they do.** When I wrote the golden examples, I placed points that I expected to fall "in the middle zone" by eyeballing coordinates. The ray-casting algorithm disagreed. The fix was to run the algorithm against the candidate points first, check actual containment, then write the expected output from that — not the other way around. Obvious in retrospect, but it cost time. + +**`structuredClone` is not available at the VS Code tsconfig target.** The TypeScript implementation needs to deep-clone the input feature to satisfy the no-mutation requirement, and `structuredClone` is the natural choice in modern JS. The VS Code extension targets ES2020, where it doesn't exist. Switched to `JSON.parse(JSON.stringify(...))` — verbose, but it works everywhere the code needs to run and doesn't require a polyfill. + +**Innermost-first ordering is load-bearing.** The zones from buffer-zone-generator are ordered innermost (index 0, highest detection likelihood) to outermost. A point near the track centre is geometrically inside all three zone polygons simultaneously — the 25% zone physically contains the 50% zone, which contains the 75% zone. Testing innermost-first means the first match is always the most specific. If I'd tested outermost-first, every point near the centre would have come out labelled 25% because the outer polygon contains them too. + +## What's Next + +Step 5 is the zone histogram generator (#082): count classified points per zone and produce a Vega-Lite histogram showing detection probability distribution across the analysis area. When that's done, the E03 chain is complete and the reactive PROV cascade — move the track, watch steps 3 through 5 rerun automatically — can be demonstrated end-to-end. diff --git a/specs/081-point-in-zone-classifier/plan.md b/specs/081-point-in-zone-classifier/plan.md new file mode 100644 index 00000000..39fe4c47 --- /dev/null +++ b/specs/081-point-in-zone-classifier/plan.md @@ -0,0 +1,105 @@ +# Implementation Plan: Point-in-Zone Classifier + +**Branch**: `claude/speckit-start-081-3Btda` | **Date**: 2026-02-17 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/081-point-in-zone-classifier/spec.md` + +## Summary + +Implement a point-in-zone classifier tool that classifies reference points (MultiPoint feature from #078) by testing each coordinate against concentric detection zone polygons (MultiPolygon feature from #080). Uses a ray-casting point-in-polygon algorithm with innermost-first zone priority. Updates per-point metadata with zone name and color, and adds a `pointColors` array for per-point rendering. Implemented in Python (debrief-calc) and TypeScript (web-shell + VS Code), registered in all three tool registries. + +## Technical Context + +**Language/Version**: Python 3.11 (service), TypeScript 5.x (frontends) +**Primary Dependencies**: stdlib only (math, copy, uuid); no external geometry libraries +**Storage**: N/A — stateless tool, caller handles STAC persistence via PROV system +**Testing**: pytest (Python), vitest/jest (TypeScript), golden example validation +**Target Platform**: Cross-platform (Linux, macOS, Windows) — runs offline +**Project Type**: Multi-workspace (services/calc + apps/web-shell + apps/vscode) +**Performance Goals**: Classify 10,000 points against 3 zones in < 1 second +**Constraints**: Offline-capable (Constitution I), no external dependencies (Constitution IX), stdlib only +**Scale/Scope**: Typical usage: 25-625 reference points, 3 zones. Max tested: 10,000 points. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Article | Requirement | Status | Notes | +|---------|-------------|--------|-------| +| I. Defence-Grade Reliability | Offline by default | PASS | No network calls. Pure geometry. | +| I. Defence-Grade Reliability | No silent failures | PASS | Explicit errors for invalid input. | +| I. Defence-Grade Reliability | Reproducibility | PASS | Deterministic: same inputs → same output. | +| II. Schema Integrity | Schema compliance | PASS | Uses existing FeatureKindEnum, PointMetadataEntry. Extends metadata with zone/color (non-breaking). | +| III. Data Sovereignty | Provenance always | PASS | ToolResponse includes debrief:sourceFeatures, debrief:resultType, debrief:label. | +| IV. Architectural Boundaries | Services never touch UI | PASS | Returns data only (classified feature). Renderer interprets pointColors. | +| IV. Architectural Boundaries | Services have zero MCP dependency | PASS | Pure Python logic. @tool wrapper is thin MCP layer. | +| VI. Testing | Services require unit tests | PASS | Golden examples + unit tests for both languages. | +| VII. Test-Driven AI | Tests before implementation | PASS | Golden I/O examples already created. Tests define done. | +| VIII. Documentation | Specs before code | PASS | Tool spec and feature spec complete. | +| IX. Dependencies | Minimal dependencies | PASS | stdlib only. No shapely, no turf.js. | +| XIV. Pre-Release Freedom | Breaking changes permitted | N/A | Additive change only (new tool + metadata fields). | + +**Gate Result**: ALL PASS — no violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/081-point-in-zone-classifier/ +├── spec.md # Feature specification +├── plan.md # This file +├── research.md # Algorithm and technology decisions +├── data-model.md # Entity definitions and relationships +├── quickstart.md # Usage examples for Python and TypeScript +├── contracts/ +│ └── tool-contract.yaml # MCP tool interface contract +└── media/ + ├── planning-post.md # Blog post draft + └── linkedin-planning.md # LinkedIn summary +``` + +### Source Code (repository root) + +```text +services/calc/debrief_calc/tools/ +├── __init__.py # Add: from debrief_calc.tools.reference import classification +└── reference/ + ├── __init__.py # Add: from . import classification + └── classification.py # NEW: point_in_zone_classifier() + +services/calc/tests/tools/reference/ +└── test_classification.py # NEW: pytest tests + golden example validation + +apps/web-shell/src/tools/reference/classification/ +├── pointInZoneClassifier.ts # NEW: toolDefinition + execute +├── pointInZoneClassifier.test.ts # NEW: unit tests +└── index.ts # NEW: barrel export + +apps/web-shell/src/services/ +└── toolService.ts # MODIFY: register pointInZoneClassifier + +apps/vscode/src/tools/reference/classification/ +├── pointInZoneClassifier.ts # NEW: toolDefinition + execute +└── index.ts # NEW: barrel export + +shared/tools/reference/classification/ +├── point-in-zone-classifier.1.0.md # Tool spec (already created) +├── point-in-zone-classifier.basic.input.json +├── point-in-zone-classifier.basic.output.json +├── point-in-zone-classifier.all-outside.input.json +└── point-in-zone-classifier.all-outside.output.json +``` + +**Structure Decision**: Follows existing tool organisation pattern. Python tool in `services/calc/debrief_calc/tools/reference/classification.py`, mirroring `reference/generation.py`. TypeScript in both `apps/web-shell` and `apps/vscode` under `tools/reference/classification/`. Three-way registration (Python @tool, TypeScript web-shell Map, VS Code barrel). + +## Media Components + +None — backend/infrastructure feature. The point-in-zone classifier is a pure data transformation tool with no visual UI components. Its visual effect (recolored points on the map) is rendered by the existing map component using the `pointColors` array — no new Storybook stories needed. + +## Storybook E2E Testing + +None — no interactive UI components. The tool's output is consumed by the existing map renderer which already has its own visual tests. + +## Complexity Tracking + +No violations to justify — all constitution checks pass. diff --git a/specs/081-point-in-zone-classifier/quickstart.md b/specs/081-point-in-zone-classifier/quickstart.md new file mode 100644 index 00000000..d00c6316 --- /dev/null +++ b/specs/081-point-in-zone-classifier/quickstart.md @@ -0,0 +1,87 @@ +# Quickstart: Point-in-Zone Classifier + +## What It Does + +Classifies reference points by which detection zone they fall within, updating each point's metadata with the zone name and color. Points outside all zones get `zone: "none"` and `color: "#666666"`. + +## Prerequisites + +1. Reference points generated via generate-reference-points (#078) +2. Detection zones generated via buffer-zone-generator (#080) + +## Usage + +### Python (debrief-calc MCP) + +```python +from debrief_calc.models import ContextType, SelectionContext +from debrief_calc.tools.reference.classification import point_in_zone_classifier + +# ref_feature: MultiPoint feature from generate-reference-points +# zone_feature: MultiPolygon feature from buffer-zone-generator + +context = SelectionContext( + type=ContextType.MULTI, + features=[ref_feature, zone_feature], +) + +result = point_in_zone_classifier(context, {}) +# result: [classified MultiPoint feature with updated pointMetadata and pointColors] + +classified = result[0] +print(classified["properties"]["pointMetadata"][0]) +# {"index": 0, "name": "Ref 1", "zone": "75%", "color": "#9C27B0"} + +print(classified["properties"]["pointColors"]) +# ["#9C27B0", "#9C27B0", "#F44336", "#FF9800", "#666666", "#666666"] +``` + +### TypeScript (web-shell / VS Code) + +```typescript +import { execute, toolDefinition } from './tools/reference/classification/pointInZoneClassifier'; + +const features = [refFeature, zoneFeature]; +const result = execute(features, {}); +// result: [classified MultiPoint feature] + +const classified = result[0]; +console.log(classified.properties.pointMetadata[0]); +// { index: 0, name: "Ref 1", zone: "75%", color: "#9C27B0" } + +console.log(classified.properties.pointColors); +// ["#9C27B0", "#9C27B0", "#F44336", "#FF9800", "#666666", "#666666"] +``` + +## How Classification Works + +1. For each coordinate in the MultiPoint geometry: + - Test against zone polygons in order (innermost first → outermost) + - First zone that contains the point wins (innermost = highest likelihood) + - If no zone contains the point → "none" +2. Update the `pointMetadata` entry for that coordinate with `zone` and `color` +3. Build a `pointColors` array for the renderer + +## Testing + +```bash +# Run golden example validation +cd services/calc +uv run pytest tests/tools/reference/test_classification.py -v + +# Run TypeScript tests +cd apps/web-shell +pnpm test -- --grep "point-in-zone" +``` + +## File Locations + +| Artifact | Path | +|----------|------| +| Tool spec | `shared/tools/reference/classification/point-in-zone-classifier.1.0.md` | +| Python impl | `services/calc/debrief_calc/tools/reference/classification.py` | +| Python tests | `services/calc/tests/tools/reference/test_classification.py` | +| TS impl | `apps/web-shell/src/tools/reference/classification/pointInZoneClassifier.ts` | +| TS tests | `apps/web-shell/src/tools/reference/classification/pointInZoneClassifier.test.ts` | +| VS Code impl | `apps/vscode/src/tools/reference/classification/pointInZoneClassifier.ts` | +| Golden examples | `shared/tools/reference/classification/point-in-zone-classifier.*.json` | diff --git a/specs/081-point-in-zone-classifier/research.md b/specs/081-point-in-zone-classifier/research.md new file mode 100644 index 00000000..f567b569 --- /dev/null +++ b/specs/081-point-in-zone-classifier/research.md @@ -0,0 +1,85 @@ +# Research: Point-in-Zone Classifier Tool + +**Feature**: 081-point-in-zone-classifier +**Date**: 2026-02-17 + +## Research Questions + +### RQ-1: Point-in-Polygon Algorithm Choice + +**Decision**: Ray-casting algorithm (even-odd rule) + +**Rationale**: The ray-casting algorithm is simple, well-understood, and language-neutral. It works correctly for both convex and concave polygons and has O(n) complexity per point where n is the number of polygon edges. Buffer zone polygons from the buffer-zone-generator are convex hulls, so even simpler algorithms would work, but ray-casting handles all polygon shapes. + +**Alternatives considered**: +- **Winding number algorithm**: More robust for self-intersecting polygons, but unnecessary — buffer zones are convex hulls that never self-intersect. Also more complex to implement. +- **Shapely/turf.js library**: Would add external dependencies. Constitution Article IX requires minimal dependencies, and Article I requires offline operation. Pure implementation avoids both concerns. +- **Bounding box pre-filter**: Could optimise by checking bbox before full point-in-polygon. Not worth the complexity for the expected point counts (< 10,000). + +### RQ-2: Input Context Type + +**Decision**: `ContextType.MULTI` with `input_kinds: ["POINT", "ZONE"]` + +**Rationale**: The tool needs exactly two features — one reference point set and one zone set. The MULTI context type allows multiple features. Validation within the tool ensures exactly one POINT/REFERENCE and one ZONE feature. + +**Alternatives considered**: +- **Two separate tool calls**: Would require the user to pass features separately. More complex UX and breaks the natural "select both and classify" pattern. +- **ContextType.SINGLE**: Too restrictive — only allows one feature. + +### RQ-3: Output Result Type + +**Decision**: `mutation/reference/classified_points` + +**Rationale**: The classifier modifies the existing reference point feature by adding zone/color metadata. This is a mutation (modifying an existing feature), not an addition (creating new features). The `reference/classified_points` subtype follows the naming convention (lowercase, underscores, two segments). + +**Alternatives considered**: +- `addition/reference/classified_points`: Incorrect — we're modifying, not adding. +- `mutation/reference/recolored`: Too vague — "classified" better describes the semantic operation. + +### RQ-4: Per-Point Color Representation + +**Decision**: `pointColors` array property on the feature, parallel to coordinates. + +**Rationale**: A simple array of hex color strings indexed parallel to the MultiPoint coordinates. This is the simplest possible structure for renderers to consume — just `pointColors[i]` for the i-th coordinate. It mirrors the `pointMetadata` parallel array pattern already established by generate-reference-points. + +**Alternatives considered**: +- **Feature-level style with color map**: Would require the renderer to look up zones and resolve colors. More complex. +- **Separate colored Point features**: Would break the single-MultiPoint-feature convention and create thousands of features. +- **CSS class-based approach**: Not applicable to GeoJSON/Leaflet rendering. + +### RQ-5: Zone Color Source + +**Decision**: Read from `zone_feature.properties.zones[i].style.fill_color` (falling back to `.style.color`). + +**Rationale**: The buffer-zone-generator stores per-zone styling in the `zones` array metadata. Using the source feature's own colors ensures visual consistency — if zones are regenerated with different colors, the classifier automatically picks up the changes. + +**Alternatives considered**: +- **Hardcoded color map**: Fragile — would break if zone colors change. +- **Separate color configuration parameter**: Over-engineering for a tool that should "just work" with its upstream input. + +### RQ-6: Antimeridian Handling + +**Decision**: Standard ray-casting works for typical analysis areas. Antimeridian-aware wrapping is documented as an edge case but not special-cased in the algorithm. + +**Rationale**: Buffer zones are generated from tracks that are typically in a contiguous geographic area. The ray-casting algorithm works correctly as long as the polygon and points are in the same coordinate space. The buffer-zone-generator already handles antimeridian wrapping in its output polygons. + +**Alternatives considered**: +- **Shift-and-test approach**: Shift all coordinates to avoid the antimeridian, then shift back. This adds complexity for a scenario that is unlikely in buffer zone analysis. + +### RQ-7: No External Dependencies + +**Decision**: Pure Python (stdlib math) and pure TypeScript implementations. No shapely, turf.js, or other geometry libraries. + +**Rationale**: Constitution Article IX (minimal dependencies) and Article I (offline by default). The ray-casting algorithm is trivial to implement in both languages with no dependencies. + +## Technology Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Algorithm | Ray-casting | Simple, O(n), correct for convex/concave | +| Python deps | stdlib only (math, copy, uuid) | Constitution IX | +| TS deps | none beyond project types | Constitution IX | +| Context type | MULTI | Two features needed | +| Result type | mutation | Modifying existing feature | +| Color source | zones[].style.fill_color | Consistency with upstream | +| Per-point colors | pointColors array | Parallel to coordinates | diff --git a/specs/081-point-in-zone-classifier/spec.md b/specs/081-point-in-zone-classifier/spec.md new file mode 100644 index 00000000..b07a7980 --- /dev/null +++ b/specs/081-point-in-zone-classifier/spec.md @@ -0,0 +1,168 @@ +# Feature Specification: Point-in-Zone Classifier Tool + +**Feature Branch**: `081-point-in-zone-classifier` +**Created**: 2026-02-17 +**Status**: Draft +**Input**: User description: "Implement point-in-zone-classifier tool [E03] — classify and recolor reference points by buffer zone membership (requires #049, #078, #080)" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Classify reference points by buffer zone (Priority: P1) + +An analyst has generated a grid of reference points (#078) and buffer detection zones (#080) around a track. They invoke the point-in-zone classifier to determine which detection zone each reference point falls within. The tool examines every coordinate in the reference point MultiPoint feature, tests each against the concentric zone polygons (innermost first), and assigns the most specific zone to each point. The `pointMetadata` array on the MultiPoint feature is updated with `zone` and `color` fields, and the feature-level style is set to use per-point coloring. The analyst sees the reference points recolored on the map — purple for 75% zone, red for 50% zone, orange for 25% zone, and grey for points outside all zones. + +**Why this priority**: This is the core purpose of the tool. Without classification and recoloring, the buffer zone analysis chain cannot visually communicate detection likelihood across the analysis area. + +**Independent Test**: Can be verified by providing a set of reference points with known coordinates and a set of zone polygons with known boundaries, running the classifier, and checking that each point's metadata contains the correct zone assignment and color. + +**Acceptance Scenarios**: + +1. **Given** a MultiPoint reference feature with 12 points and a MultiPolygon zone feature with 3 concentric zones (75% at 3nm, 50% at 6nm, 25% at 12nm), **When** point-in-zone-classifier is invoked, **Then** each point's `pointMetadata` entry is updated with a `zone` field (the zone name, e.g. "75%") and a `color` field (the zone's style color), and points outside all zones have `zone: "none"` and `color: "#666666"`. +2. **Given** a point that falls inside the innermost zone (75%), **When** the classifier runs, **Then** the point is assigned zone "75%" and color "#9C27B0" (purple), not the 50% or 25% zones — the most specific (innermost) zone wins. +3. **Given** a point that falls inside the 50% zone but outside the 75% zone, **When** the classifier runs, **Then** the point is assigned zone "50%" and color "#F44336" (red). + +--- + +### User Story 2 - Preserve existing point metadata (Priority: P2) + +An analyst has reference points with existing metadata (index, name) from the generate-reference-points tool. When running the classifier, existing metadata fields must be preserved — only `zone` and `color` fields are added or updated. This ensures the downstream histogram tool (#082) can still access point indices and names. + +**Why this priority**: Data integrity across the tool chain is essential. The classifier extends metadata without destroying it. + +**Independent Test**: Can be verified by providing reference points with custom metadata fields and confirming they survive classification unchanged. + +**Acceptance Scenarios**: + +1. **Given** a MultiPoint feature where each `pointMetadata` entry has `index` and `name` fields, **When** the classifier runs, **Then** each entry retains its `index` and `name` fields and gains `zone` and `color` fields. +2. **Given** a MultiPoint feature that was previously classified (already has `zone` and `color` in metadata), **When** the classifier is re-invoked with different zones, **Then** the `zone` and `color` fields are updated to reflect the new zone geometry. + +--- + +### User Story 3 - Cascade integration with E03 pipeline (Priority: P3) + +As part of the E03 reactive PROV cascade, when the buffer zones change (due to track movement in step 2), the classifier automatically re-executes with the updated zones. The reclassified points feed into the histogram generator (#082). The classifier must be stateless and deterministic — given the same inputs, it always produces the same output. + +**Why this priority**: Cascade integration is the ultimate goal of E03, but the tool must work correctly in isolation first. Statelessness enables safe re-execution via PROV replay. + +**Independent Test**: Can be verified by invoking the classifier twice with identical inputs and confirming identical outputs, then invoking with modified zone geometry and confirming the output changes correspondingly. + +**Acceptance Scenarios**: + +1. **Given** the same reference points and zone feature, **When** the classifier is invoked twice, **Then** both invocations produce identical output. +2. **Given** the classifier was previously run with zones at 3/6/12nm, **When** it is re-invoked with zones at 5/10/20nm, **Then** the point classifications change to reflect the new zone boundaries. + +--- + +### Edge Cases + +- What happens when the reference feature has no coordinates (empty MultiPoint)? Return the feature unchanged with an empty pointMetadata array. +- What happens when no zone feature is provided? Return an error: "Requires at least one zone feature". +- What happens when the zone feature has no polygons in its MultiPolygon? Return an error: "Zone feature has no polygons". +- What happens when a point falls exactly on a zone boundary? The point is assigned to the zone whose polygon contains the boundary (standard point-in-polygon inclusion treats boundary points as inside). +- What happens when multiple zone features are provided? Only the first zone feature (with `kind: "ZONE"`) is used; others are ignored. +- What happens when the reference feature is not a MultiPoint? Return an error: "Reference feature must have MultiPoint geometry". +- What happens when the pointMetadata array length doesn't match the coordinates length? Return an error: "pointMetadata length must match coordinates length". +- What happens when zones overlap but are not concentric? Each point is assigned to the zone with the highest detection likelihood (smallest polygon index, since zones are ordered innermost-first). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Tool spec MUST follow the #049 tool documentation model with all 9 required sections (metadata, description, MCP, inputs, outputs, algorithm, edge cases, examples, changelog). +- **FR-002**: Tool MUST accept two input features: one MultiPoint feature with `kind: "POINT"` and `locationType: "REFERENCE"`, and one MultiPolygon feature with `kind: "ZONE"`. The tool uses `ContextType.MULTI` input context. +- **FR-003**: Tool MUST classify each coordinate in the MultiPoint geometry against the zone polygons in the MultiPolygon geometry, testing innermost (index 0) first. +- **FR-004**: Tool MUST update each `pointMetadata` entry with: + - `zone`: The zone name (e.g., "75%", "50%", "25%") or "none" if outside all zones + - `color`: The zone's style color (from the `zones` array) or "#666666" for points outside all zones +- **FR-005**: Tool MUST preserve all existing fields in each `pointMetadata` entry (e.g., `index`, `name`). +- **FR-006**: Tool MUST use the ray-casting algorithm (or equivalent) for point-in-polygon testing. +- **FR-007**: Tool MUST test zones in order from innermost (index 0, highest likelihood) to outermost (last index, lowest likelihood), assigning the first matching zone. +- **FR-008**: Tool MUST return a `mutation`-type ToolResponse with result subtype `reference/classified_points`, containing the modified MultiPoint feature with updated pointMetadata. +- **FR-009**: Tool MUST record provenance annotations including source feature IDs for both the reference points and the zone feature. +- **FR-010**: Tool MUST set per-point colors on the feature's `pointColors` property — an array parallel to coordinates where each entry is the assigned zone color. This enables renderers to draw each point in its classification color. +- **FR-011**: Tool MUST work entirely offline with no network dependency. +- **FR-012**: Tool MUST produce at least 2 golden I/O example files (basic classification and all-outside-zones). +- **FR-013**: Tool MUST handle the antimeridian correctly when testing point containment in zone polygons. +- **FR-014**: Tool MUST be stateless — given the same inputs it MUST produce identical output. + +### Key Entities + +- **Reference Point Set**: A GeoJSON MultiPoint Feature with `kind: "POINT"` and `locationType: "REFERENCE"`. Contains all reference coordinates in geometry, with a parallel `pointMetadata` array for per-point information. This is the input to be classified and the output after classification (with updated metadata). +- **Detection Zone Feature**: A GeoJSON MultiPolygon Feature with `kind: "ZONE"`. Contains concentric zone polygons ordered innermost (highest likelihood) to outermost (lowest likelihood). Each zone's metadata is in the `zones` array property (name, detection_likelihood_pct, buffer_distance_nm, style). +- **Point Metadata Entry**: An element of the `pointMetadata` array, indexed parallel to the MultiPoint coordinates. The classifier adds/updates `zone` (string) and `color` (hex string) fields while preserving existing fields (index, name, etc.). +- **Point Colors Array**: A `pointColors` property on the classified feature — an array of hex color strings parallel to coordinates, enabling per-point rendering. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Tool spec file exists at `shared/tools/reference/classification/point-in-zone-classifier.1.0.md` with all 9 required sections complete. +- **SC-002**: At least 2 golden I/O example pairs exist (basic classification and all-outside-zones). +- **SC-003**: Algorithm pseudocode correctly implements point-in-polygon testing with innermost-first zone priority. +- **SC-004**: Edge cases table covers at minimum: empty points, no zones, boundary points, re-classification, mismatched metadata length. +- **SC-005**: Given the generate-reference-points grid output and buffer-zone-generator output, the classifier correctly assigns each point to the appropriate zone based on geometric containment. +- **SC-006**: All existing `pointMetadata` fields survive classification unchanged — only `zone` and `color` fields are added/updated. +- **SC-007**: The output is a valid GeoJSON MultiPoint feature conforming to the project schema. + +## Deliverables + +| Deliverable | Path | +|-------------|------| +| Feature spec | `specs/081-point-in-zone-classifier/spec.md` | +| Tool spec | `shared/tools/reference/classification/point-in-zone-classifier.1.0.md` | +| Golden example (basic) | `shared/tools/reference/classification/point-in-zone-classifier.basic.input.json` | +| Golden example (basic output) | `shared/tools/reference/classification/point-in-zone-classifier.basic.output.json` | +| Golden example (outside) | `shared/tools/reference/classification/point-in-zone-classifier.all-outside.input.json` | +| Golden example (outside output) | `shared/tools/reference/classification/point-in-zone-classifier.all-outside.output.json` | + +## Technical Notes + +### Point-in-Polygon Algorithm + +The ray-casting algorithm is used for point containment testing: + +``` +Cast a horizontal ray from the point to the right (+longitude). +Count the number of times the ray crosses polygon edges. +If odd → point is inside. If even → point is outside. +``` + +This algorithm correctly handles concave polygons, which buffer zone polygons may approximate via convex hull. + +### Zone Testing Order + +Zones in the MultiPolygon are ordered innermost (index 0) to outermost (last index). For each point: + +1. Test against zone 0 (e.g., 75%, 3nm) — if inside, assign this zone +2. If not, test against zone 1 (e.g., 50%, 6nm) — if inside, assign +3. If not, test against zone 2 (e.g., 25%, 12nm) — if inside, assign +4. If not inside any zone, assign "none" + +Since zones are concentric (each larger zone fully contains all smaller zones), the first match is always the most specific. + +### Color Mapping + +The classifier reads colors from the zone feature's `zones` array, where each entry has a `style.fill_color` (or `style.color`) property: + +| Zone | Default Color | Hex | +|------|--------------|-----| +| 75% (inner) | Purple | #9C27B0 | +| 50% (middle) | Red | #F44336 | +| 25% (outer) | Orange | #FF9800 | +| None (outside) | Grey | #666666 | + +### Dependencies + +- Requires #049 (tool documentation model) — **complete** +- Requires #078 (generate reference points) — **specified** — produces the MultiPoint input +- Requires #080 (buffer zone generator) — **specified** — produces the zone MultiPolygon input +- Downstream consumer: #082 (zone histogram generator) — reads classified points + +## Assumptions + +- The zone feature uses MultiPolygon geometry with polygons ordered innermost to outermost, as produced by the buffer-zone-generator (#080). +- The reference feature uses MultiPoint geometry with a parallel `pointMetadata` array, as produced by generate-reference-points (#078). +- Colors are taken from the `zones` array metadata on the zone feature, not from feature-level style. +- The ray-casting algorithm is sufficient for the polygon shapes produced by the buffer-zone-generator (convex hulls). For highly irregular polygons, winding number could be used instead, but this is out of scope. +- Per-point rendering is supported by the map renderer via the `pointColors` array property (an array of hex strings parallel to coordinates). +- The classifier is a pure geometric operation — it does not need access to temporal data or track properties. diff --git a/specs/081-point-in-zone-classifier/tasks.md b/specs/081-point-in-zone-classifier/tasks.md new file mode 100644 index 00000000..10d5bef3 --- /dev/null +++ b/specs/081-point-in-zone-classifier/tasks.md @@ -0,0 +1,51 @@ +# Tasks: Point-in-Zone Classifier + +**Feature**: 081-point-in-zone-classifier +**Branch**: `claude/speckit-start-081-3Btda` +**Generated**: 2026-02-17 + +## Phase 1: Setup + +- [x] T101 Create directory structure for tool spec and golden examples +- [x] T102 Create feature specification `specs/081-point-in-zone-classifier/spec.md` +- [x] T103 Create tool spec `shared/tools/reference/classification/point-in-zone-classifier.1.0.md` +- [x] T104 Create golden examples (basic + all-outside input/output pairs) + +## Phase 2: Python Implementation + +- [x] T201 Create `services/calc/debrief_calc/tools/reference/classification.py` with `point_in_zone_classifier` function +- [x] T202 Implement ray-casting `_point_in_polygon` algorithm +- [x] T203 Implement zone classification loop (innermost-first priority) +- [x] T204 Register tool via `@tool` decorator with `ContextType.MULTI` +- [x] T205 Add import in `services/calc/debrief_calc/tools/reference/__init__.py` +- [x] T206 Add import in `services/calc/debrief_calc/tools/__init__.py` + +## Phase 3: Python Tests + +- [x] T301 [test] Create `services/calc/tests/tools/reference/test_classification.py` +- [x] T302 [test] TestClassifyBasic — 7 tests covering zone assignment and pointColors +- [x] T303 [test] TestMetadataPreservation — 4 tests covering field preservation and reclassification +- [x] T304 [test] TestDeterminism — 2 tests covering identical output and geometry preservation +- [x] T305 [test] TestEdgeCases — 7 tests covering error conditions and boundary cases +- [x] T306 [test] TestGoldenExamples — 2 tests validating against golden I/O files + +## Phase 4: TypeScript Implementation + +- [x] T401 Create `apps/vscode/src/tools/reference/classification/pointInZoneClassifier.ts` +- [x] T402 Implement `toolDefinition` with `MCPToolDefinition` interface +- [x] T403 Implement `execute` function with identical ray-casting algorithm +- [x] T404 Create barrel export `apps/vscode/src/tools/reference/classification/index.ts` +- [x] T405 Register in web-shell `apps/web-shell/src/services/toolService.ts` + +## Phase 5: Evidence & Media + +- [x] T501 Capture test results in `specs/081-point-in-zone-classifier/evidence/test-summary.md` +- [x] T502 Create usage example in `specs/081-point-in-zone-classifier/evidence/usage-example.md` +- [x] T503 Create shipped blog post in `specs/081-point-in-zone-classifier/media/shipped-post.md` +- [x] T504 Create LinkedIn shipped summary in `specs/081-point-in-zone-classifier/media/linkedin-shipped.md` + +## Phase 6: Completion + +- [x] T601 Update BACKLOG.md status to `specified` +- [x] T602 Commit and push all changes +- [ ] T603 Create PR and publish blog: gh CLI lacks GitHub API token in this environment — PR body prepared, create manually