From 9f6c703757689c781918163a8bb2694ceefd96d3 Mon Sep 17 00:00:00 2001 From: Aaro Koinsaari <89689072+koinsaari@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:24:59 +0300 Subject: [PATCH] feat: replace measurement floats with traffic-light accessibility levels Entrance/pathway/restroom/parking/elevator measurement fields (width, slope, turning radius, etc.) now take an AccessibilityLevel enum (good/limited/no) instead of a raw float, submitted directly rather than measured. OSM ingestion still converts its two raw-value tags (width/door:width, incline) via threshold tables cited to SIA 500/ADA; the remaining measurement fields have no OSM equivalent and are populated only through direct submission. --- api/openapi.yaml | 64 ++++------------ cmd/api/integration_test.go | 4 +- internal/a11y/README.md | 22 +++--- internal/a11y/engine.go | 60 ++++++++++----- internal/a11y/engine_test.go | 83 +++++++++++++++----- internal/api/v1/server.gen.go | 122 ++++++++++++++++++------------ internal/sources/osm/README.md | 4 + internal/sources/osm/a11y.go | 41 ++++++++++ internal/sources/osm/a11y_test.go | 57 ++++++++++++++ pkg/models/README.md | 4 +- pkg/models/accessibility.go | 121 ++++++++++++++++------------- 11 files changed, 383 insertions(+), 199 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 99e72ee..17f3a42 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -32,6 +32,10 @@ components: type: string enum: [asphalt, paving_stones, cobblestone, gravel, concrete, wood, carpet, tiles] + AccessibilityLevel: + type: string + enum: [good, limited, no] + Category: type: string enum: @@ -68,10 +72,7 @@ components: type: $ref: "#/components/schemas/DoorType" width: - type: number - format: double - minimum: 0 - maximum: 10 + $ref: "#/components/schemas/AccessibilityLevel" EntranceProps: x-go-type: models.EntranceProps @@ -87,15 +88,9 @@ components: has_removable_ramp: type: boolean slope_percent: - type: number - format: double - minimum: 0 - maximum: 100 + $ref: "#/components/schemas/AccessibilityLevel" width: - type: number - format: double - minimum: 0 - maximum: 10 + $ref: "#/components/schemas/AccessibilityLevel" door: $ref: "#/components/schemas/DoorProps" has_intercom: @@ -114,10 +109,7 @@ components: type: object properties: width: - type: number - format: double - minimum: 0 - maximum: 20 + $ref: "#/components/schemas/AccessibilityLevel" surface: $ref: "#/components/schemas/SurfaceType" is_kerbstone_free: @@ -140,24 +132,15 @@ components: is_accessible: type: boolean door_width: - type: number - format: double - minimum: 0 - maximum: 10 + $ref: "#/components/schemas/AccessibilityLevel" turning_radius: - type: number - format: double - minimum: 0 - maximum: 10 + $ref: "#/components/schemas/AccessibilityLevel" has_grab_rails: type: boolean has_roll_in_shower: type: boolean toilet_seat_height: - type: number - format: double - minimum: 0 - maximum: 1 + $ref: "#/components/schemas/AccessibilityLevel" has_emergency_pull: type: boolean has_changing_table: @@ -182,15 +165,9 @@ components: minimum: 0 maximum: 10000 distance_to_entrance: - type: number - format: double - minimum: 0 - maximum: 10000 + $ref: "#/components/schemas/AccessibilityLevel" width: - type: number - format: double - minimum: 0 - maximum: 20 + $ref: "#/components/schemas/AccessibilityLevel" has_dedicated_signage: type: boolean audit_flags: @@ -207,20 +184,11 @@ components: type: object properties: width: - type: number - format: double - minimum: 0 - maximum: 10 + $ref: "#/components/schemas/AccessibilityLevel" depth: - type: number - format: double - minimum: 0 - maximum: 10 + $ref: "#/components/schemas/AccessibilityLevel" door_width: - type: number - format: double - minimum: 0 - maximum: 10 + $ref: "#/components/schemas/AccessibilityLevel" has_braille: type: boolean has_audio: diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go index 5d41326..ffb2400 100644 --- a/cmd/api/integration_test.go +++ b/cmd/api/integration_test.go @@ -133,7 +133,7 @@ func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) { t.Cleanup(func() { truncate(t) }) // narrow width sets an audit flag but must not block the write - narrowWidth := 0.75 + narrowWidth := models.LevelNo body, _ := json.Marshal(models.Place{ Name: "Narrow Cafe", Lat: 52.5, @@ -162,7 +162,7 @@ func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) { } found := false for _, f := range place.Accessibility.Entrance.AuditFlags { - if f == "narrow width (0.8m required)" { + if f == a11y.FlagEntranceNarrowWidth { found = true } } diff --git a/internal/a11y/README.md b/internal/a11y/README.md index f4c3625..973e7b1 100644 --- a/internal/a11y/README.md +++ b/internal/a11y/README.md @@ -7,30 +7,32 @@ Pure rule engine that runs synchronously on every write touching an `Accessibili ## Core principles 1. **Data fidelity.** The API stores what is submitted. It does not compute whether a place is accessible — that judgement belongs to the client, which knows the user's specific needs. -2. **Facts over opinions.** `AuditFlags` are objective facts derived from the submitter's own property values (e.g. entrance width < 0.8m is a measurable fact). They are stored for clients to use, not for the server to act on. +2. **Facts over opinions.** `AuditFlags` are objective facts derived from the submitter's own property values (e.g. entrance width rated `no` is a measurable fact). They are stored for clients to use, not for the server to act on. 3. **Specific overrides general.** A child place's own component data always takes precedence over the parent's equivalent component. 4. **No write is ever rejected on accessibility grounds.** There is no conflict detection and no 422 response from this package. Audit flags are informational only. ## AuditFlags -`WithAuditFlags` recomputes `AuditFlags` on every non-nil component on every write. The flags are deterministic derivations of what the submitter themselves provided: +`WithAuditFlags` recomputes `AuditFlags` on every non-nil component on every write. Measurement fields (`width`, `slope_percent`, `door_width`, `turning_radius`, `toilet_seat_height`, `distance_to_entrance`, `depth`) are submitted as an `AccessibilityLevel` (`good`/`limited`/`no`) rather than a raw number — the submitter rates the field directly, no measuring required. Flags fire only on `no`: | Component | Flag | Condition | |---|---|---| -| Entrance | `narrow width (0.8m required)` | `width < 0.8m` | +| Entrance | `narrow width` | `width = no` | | Entrance | `no level route (no ramp and not level)` | `is_level = false` and neither `has_fixed_ramp` nor `has_removable_ramp` is true | -| Pathways | `narrow pathway (0.9m required)` | `width < 0.9m` | -| Restroom | `narrow door (0.8m required)` | `door_width < 0.8m` | -| Restroom | `small turning radius (1.5m required)` | `turning_radius < 1.5m` | +| Pathways | `narrow pathway` | `width = no` | +| Restroom | `narrow door` | `door_width = no` | +| Restroom | `small turning radius` | `turning_radius = no` | | Restroom | `missing grab rails` | `has_grab_rails = false` | | Parking | `no disabled spaces` | `has_disabled_spaces = false` | -| Elevator | `small cabin width (0.8m required)` | `width < 0.8m` | -| Elevator | `small cabin depth (1.1m required)` | `depth < 1.1m` | -| Elevator | `narrow door (0.8m required)` | `door_width < 0.8m` | +| Elevator | `small cabin width` | `width = no` | +| Elevator | `small cabin depth` | `depth = no` | +| Elevator | `narrow door` | `door_width = no` | | Elevator | `missing braille` | `has_braille = false` | | Elevator | `missing audio` | `has_audio = false` | -A field left `nil` means "unknown" and never triggers a flag — flags only fire on an explicit value that fails the threshold. The engine never concludes that a place is inaccessible; clients receive the flags and apply their own relevance logic per user profile. +A field left `nil` means "unknown" and never triggers a flag — flags only fire on an explicit `no`. The engine never concludes that a place is inaccessible; clients receive the flags and apply their own relevance logic per user profile. + +`LevelForDoorWidth` and `LevelForSlopePercent` convert a raw metres/percent value into an `AccessibilityLevel` per the bands documented on the corresponding `pkg/models` struct fields (SIA 500 / ADA). They exist for ingestion sources that supply raw measurements (currently only `internal/sources/osm`, for the `width`/`door:width`/`incline` OSM tags) — API writers submit the level directly and never go through this conversion. ## Write flow diff --git a/internal/a11y/engine.go b/internal/a11y/engine.go index 2dcdaa3..2ab2f1b 100644 --- a/internal/a11y/engine.go +++ b/internal/a11y/engine.go @@ -13,24 +13,50 @@ import ( // Audit flag constants computed from submitted property values. const ( - FlagEntranceNarrowWidth = "narrow width (0.8m required)" + FlagEntranceNarrowWidth = "narrow width" FlagEntranceNoLevelRoute = "no level route (no ramp and not level)" - FlagRestroomNarrowDoor = "narrow door (0.8m required)" - FlagRestroomSmallTurning = "small turning radius (1.5m required)" - FlagRestroomNoGrabRails = "missing grab rails" + FlagRestroomNarrowDoor = "narrow door" + FlagRestroomSmallTurning = "small turning radius" + FlagRestroomNoGrabRails = "missing grab rails" - FlagElevatorNarrowWidth = "small cabin width (0.8m required)" - FlagElevatorShallowDepth = "small cabin depth (1.1m required)" - FlagElevatorNarrowDoor = "narrow door (0.8m required)" - FlagElevatorNoBraille = "missing braille" - FlagElevatorNoAudio = "missing audio" + FlagElevatorNarrowWidth = "small cabin width" + FlagElevatorShallowDepth = "small cabin depth" + FlagElevatorNarrowDoor = "narrow door" + FlagElevatorNoBraille = "missing braille" + FlagElevatorNoAudio = "missing audio" FlagParkingNoDisabledSpaces = "no disabled spaces" - FlagPathwayNarrowWidth = "narrow pathway (0.9m required)" + FlagPathwayNarrowWidth = "narrow pathway" ) +// LevelForDoorWidth maps a clear opening width in metres to an AccessibilityLevel. +// Good >=0.80m, limited 0.70-0.80m, no <0.70m (SIA 500). +func LevelForDoorWidth(metres float64) models.AccessibilityLevel { + switch { + case metres >= 0.80: + return models.LevelGood + case metres >= 0.70: + return models.LevelLimited + default: + return models.LevelNo + } +} + +// LevelForSlopePercent maps a ramp slope percentage to an AccessibilityLevel. +// Good <=6%, limited 6-8.33%, no >8.33% (ADA). +func LevelForSlopePercent(percent float64) models.AccessibilityLevel { + switch { + case percent <= 6: + return models.LevelGood + case percent <= 8.33: + return models.LevelLimited + default: + return models.LevelNo + } +} + type Engine struct{} // WithAuditFlags computes AuditFlags on each non-nil component of the profile. @@ -124,7 +150,7 @@ func (e *Engine) ComputeEffectiveProfile(child, parent *models.Place) *models.Ac func entranceFlags(p *models.EntranceProps) []string { var flags []string - if p.Width != nil && *p.Width < 0.8 { + if p.Width != nil && *p.Width == models.LevelNo { flags = append(flags, FlagEntranceNarrowWidth) } // Only flag when IsLevel is explicitly false; nil means unknown, no flag. @@ -139,7 +165,7 @@ func entranceFlags(p *models.EntranceProps) []string { func pathwayFlags(p *models.PathwayProps) []string { var flags []string - if p.Width != nil && *p.Width < 0.9 { + if p.Width != nil && *p.Width == models.LevelNo { flags = append(flags, FlagPathwayNarrowWidth) } return flags @@ -147,10 +173,10 @@ func pathwayFlags(p *models.PathwayProps) []string { func restroomFlags(p *models.RestroomProps) []string { var flags []string - if p.DoorWidth != nil && *p.DoorWidth < 0.8 { + if p.DoorWidth != nil && *p.DoorWidth == models.LevelNo { flags = append(flags, FlagRestroomNarrowDoor) } - if p.TurningRadius != nil && *p.TurningRadius < 1.5 { + if p.TurningRadius != nil && *p.TurningRadius == models.LevelNo { flags = append(flags, FlagRestroomSmallTurning) } if p.HasGrabRails != nil && !*p.HasGrabRails { @@ -169,13 +195,13 @@ func parkingFlags(p *models.ParkingProps) []string { func elevatorFlags(p *models.ElevatorProps) []string { var flags []string - if p.Width != nil && *p.Width < 0.8 { + if p.Width != nil && *p.Width == models.LevelNo { flags = append(flags, FlagElevatorNarrowWidth) } - if p.Depth != nil && *p.Depth < 1.1 { + if p.Depth != nil && *p.Depth == models.LevelNo { flags = append(flags, FlagElevatorShallowDepth) } - if p.DoorWidth != nil && *p.DoorWidth < 0.8 { + if p.DoorWidth != nil && *p.DoorWidth == models.LevelNo { flags = append(flags, FlagElevatorNarrowDoor) } if p.HasBraille != nil && !*p.HasBraille { diff --git a/internal/a11y/engine_test.go b/internal/a11y/engine_test.go index 7aa5dfa..f12a5ec 100644 --- a/internal/a11y/engine_test.go +++ b/internal/a11y/engine_test.go @@ -12,8 +12,8 @@ import ( "github.com/InWheelOrg/inwheel-api/pkg/models" ) -func boolPtr(v bool) *bool { return &v } -func floatPtr(v float64) *float64 { return &v } +func boolPtr(v bool) *bool { return &v } +func levelPtr(v models.AccessibilityLevel) *models.AccessibilityLevel { return &v } func TestComputeEffectiveProfile(t *testing.T) { t.Parallel() @@ -127,13 +127,18 @@ func TestWithAuditFlags(t *testing.T) { wantFlags: map[string][]string{"entrance": nil}, }, { - name: "entrance: width below 0.8m", - profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: floatPtr(0.75)}}, + name: "entrance: width level no", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: levelPtr(models.LevelNo)}}, wantFlags: map[string][]string{"entrance": {FlagEntranceNarrowWidth}}, }, { - name: "entrance: width exactly 0.8m, no flag", - profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: floatPtr(0.8)}}, + name: "entrance: width level limited, no flag", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: levelPtr(models.LevelLimited)}}, + wantFlags: map[string][]string{"entrance": nil}, + }, + { + name: "entrance: width level good, no flag", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: levelPtr(models.LevelGood)}}, wantFlags: map[string][]string{"entrance": nil}, }, { @@ -154,25 +159,25 @@ func TestWithAuditFlags(t *testing.T) { // --- pathways --- { - name: "pathway: width below 0.9m", - profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: floatPtr(0.8)}}, + name: "pathway: width level no", + profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: levelPtr(models.LevelNo)}}, wantFlags: map[string][]string{"pathways": {FlagPathwayNarrowWidth}}, }, { - name: "pathway: width at 0.9m, no flag", - profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: floatPtr(0.9)}}, + name: "pathway: width level good, no flag", + profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: levelPtr(models.LevelGood)}}, wantFlags: map[string][]string{"pathways": nil}, }, // --- restroom --- { - name: "restroom: door width below 0.8m", - profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{DoorWidth: floatPtr(0.75)}}, + name: "restroom: door width level no", + profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{DoorWidth: levelPtr(models.LevelNo)}}, wantFlags: map[string][]string{"restroom": {FlagRestroomNarrowDoor}}, }, { - name: "restroom: turning radius below 1.5m", - profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{TurningRadius: floatPtr(1.2)}}, + name: "restroom: turning radius level no", + profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{TurningRadius: levelPtr(models.LevelNo)}}, wantFlags: map[string][]string{"restroom": {FlagRestroomSmallTurning}}, }, { @@ -200,18 +205,18 @@ func TestWithAuditFlags(t *testing.T) { // --- elevator --- { - name: "elevator: width below 0.8m", - profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Width: floatPtr(0.7)}}, + name: "elevator: width level no", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Width: levelPtr(models.LevelNo)}}, wantFlags: map[string][]string{"elevator": {FlagElevatorNarrowWidth}}, }, { - name: "elevator: depth below 1.1m", - profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Depth: floatPtr(1.0)}}, + name: "elevator: depth level no", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Depth: levelPtr(models.LevelNo)}}, wantFlags: map[string][]string{"elevator": {FlagElevatorShallowDepth}}, }, { - name: "elevator: door width below 0.8m", - profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{DoorWidth: floatPtr(0.75)}}, + name: "elevator: door width level no", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{DoorWidth: levelPtr(models.LevelNo)}}, wantFlags: map[string][]string{"elevator": {FlagElevatorNarrowDoor}}, }, { @@ -283,3 +288,41 @@ func TestWithAuditFlags(t *testing.T) { }) } } + +func TestLevelForDoorWidth(t *testing.T) { + t.Parallel() + tests := []struct { + metres float64 + want models.AccessibilityLevel + }{ + {0.90, models.LevelGood}, + {0.80, models.LevelGood}, + {0.75, models.LevelLimited}, + {0.70, models.LevelLimited}, + {0.65, models.LevelNo}, + } + for _, tt := range tests { + if got := LevelForDoorWidth(tt.metres); got != tt.want { + t.Errorf("LevelForDoorWidth(%v) = %v, want %v", tt.metres, got, tt.want) + } + } +} + +func TestLevelForSlopePercent(t *testing.T) { + t.Parallel() + tests := []struct { + percent float64 + want models.AccessibilityLevel + }{ + {4, models.LevelGood}, + {6, models.LevelGood}, + {7, models.LevelLimited}, + {8.33, models.LevelLimited}, + {9, models.LevelNo}, + } + for _, tt := range tests { + if got := LevelForSlopePercent(tt.percent); got != tt.want { + t.Errorf("LevelForSlopePercent(%v) = %v, want %v", tt.percent, got, tt.want) + } + } +} diff --git a/internal/api/v1/server.gen.go b/internal/api/v1/server.gen.go index 9a27e3a..9180d68 100644 --- a/internal/api/v1/server.gen.go +++ b/internal/api/v1/server.gen.go @@ -29,6 +29,27 @@ const ( ApiKeyAuthScopes apiKeyAuthContextKey = "ApiKeyAuth.Scopes" ) +// Defines values for AccessibilityLevel. +const ( + Good AccessibilityLevel = "good" + Limited AccessibilityLevel = "limited" + No AccessibilityLevel = "no" +) + +// Valid indicates whether the value is a known member of the AccessibilityLevel enum. +func (e AccessibilityLevel) Valid() bool { + switch e { + case Good: + return true + case Limited: + return true + case No: + return true + default: + return false + } +} + // Defines values for Category. const ( Airport Category = "airport" @@ -173,6 +194,9 @@ func (e SurfaceType) Valid() bool { } } +// AccessibilityLevel defines model for AccessibilityLevel. +type AccessibilityLevel string + // AccessibilityProfile defines model for AccessibilityProfile. type AccessibilityProfile = models.AccessibilityProfile @@ -1230,55 +1254,55 @@ func (sh *strictHandler) PatchPlaceAccessibility(w http.ResponseWriter, r *http. // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "AAD//w==", + "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", + "hxgOgVYaSSd0tDijq5vVvwMAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/sources/osm/README.md b/internal/sources/osm/README.md index 036788b..0e1852d 100644 --- a/internal/sources/osm/README.md +++ b/internal/sources/osm/README.md @@ -66,8 +66,12 @@ The natural key for upserts is `(osm_id, osm_type)`, where `osm_type` is `node`, | `automatic_door` not empty and ≠ `no` | `EntranceProps.Door.Type=automatic` | | `step_count` or `entrance:step_count` ≥ 1 | `EntranceProps.IsLevel=false` | | `ramp:wheelchair=yes\|no` | `EntranceProps.HasFixedRamp` (takes precedence over generic `ramp=no`) | +| `width` or `door:width` (metres, `width` takes precedence) | `EntranceProps.Width` via `a11y.LevelForDoorWidth` | +| `incline` (percentage form, e.g. `8%`; other forms like `up`/`6°` are skipped) | `EntranceProps.SlopePercent` via `a11y.LevelForSlopePercent` | | `elevator=yes` | `ElevatorProps{}` (presence only; no dimensions from OSM) | +`models.EntranceProps.Width`/`SlopePercent` and the equivalent fields on the other components are `AccessibilityLevel` (`good`/`limited`/`no`), not raw numbers — see `pkg/models/README.md`. OSM is currently the only source with raw measurement tags, and only for entrance width and ramp slope; no OSM tag maps to the other measurement fields (turning radius, toilet seat height, elevator dimensions, parking width/distance), so they are never populated by this pipeline. + There is no conflict detection anywhere in this mapping or downstream. The `wheelchair` tag is never interpreted into a computed status — it is recorded as a raw opinion in `SourceReports` and left for clients to weigh alongside the typed component facts. ## Rank derivation diff --git a/internal/sources/osm/a11y.go b/internal/sources/osm/a11y.go index cf68978..9019209 100644 --- a/internal/sources/osm/a11y.go +++ b/internal/sources/osm/a11y.go @@ -7,8 +7,10 @@ package osm import ( "strconv" + "strings" "time" + "github.com/InWheelOrg/inwheel-api/internal/a11y" "github.com/InWheelOrg/inwheel-api/pkg/models" ) @@ -114,6 +116,18 @@ func mapEntrance(tags map[string]string) *models.EntranceProps { found = true } + if v, ok := parseMetres(tags["width"], tags["door:width"]); ok { + level := a11y.LevelForDoorWidth(v) + props.Width = &level + found = true + } + + if v, ok := parsePercent(tags["incline"]); ok { + level := a11y.LevelForSlopePercent(v) + props.SlopePercent = &level + found = true + } + if !found { return nil } @@ -128,6 +142,33 @@ func stepCountPositive(v string) bool { return err == nil && n > 0 } +func parseMetres(tags ...string) (float64, bool) { + for _, v := range tags { + if v == "" { + continue + } + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f, true + } + } + return 0, false +} + +func parsePercent(v string) (float64, bool) { + v = strings.TrimSpace(v) + if !strings.HasSuffix(v, "%") { + return 0, false + } + f, err := strconv.ParseFloat(strings.TrimSuffix(v, "%"), 64) + if err != nil { + return 0, false + } + if f < 0 { + f = -f + } + return f, true +} + func mapElevator(tags map[string]string) *models.ElevatorProps { if tags["elevator"] == "yes" { return &models.ElevatorProps{} diff --git a/internal/sources/osm/a11y_test.go b/internal/sources/osm/a11y_test.go index fb57a47..a532531 100644 --- a/internal/sources/osm/a11y_test.go +++ b/internal/sources/osm/a11y_test.go @@ -204,6 +204,63 @@ func TestMapTagsToProfile_Entrance(t *testing.T) { t.Errorf("ramp=yes alone should return nil, got %+v", got) } }) + t.Run("width sets Width level", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"width": "0.9"}) + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.Width == nil || *got.Entrance.Width != models.LevelGood { + t.Errorf("Width = %v, want good", got.Entrance.Width) + } + }) + t.Run("width takes precedence over door:width", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"width": "0.9", "door:width": "0.6"}) + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.Width == nil || *got.Entrance.Width != models.LevelGood { + t.Errorf("Width = %v, want good (width tag wins when present)", got.Entrance.Width) + } + }) + t.Run("door:width used when width absent", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"door:width": "0.6"}) + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.Width == nil || *got.Entrance.Width != models.LevelNo { + t.Errorf("Width = %v, want no", got.Entrance.Width) + } + }) + t.Run("unparseable width is skipped", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"width": "narrow"}) + if got != nil { + t.Errorf("unparseable width alone should return nil, got %+v", got) + } + }) + t.Run("incline sets SlopePercent level", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"incline": "10%"}) + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.SlopePercent == nil || *got.Entrance.SlopePercent != models.LevelNo { + t.Errorf("SlopePercent = %v, want no", got.Entrance.SlopePercent) + } + }) + t.Run("negative incline is treated as its magnitude", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"incline": "-5%"}) + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.SlopePercent == nil || *got.Entrance.SlopePercent != models.LevelGood { + t.Errorf("SlopePercent = %v, want good", got.Entrance.SlopePercent) + } + }) + t.Run("non-percentage incline is skipped", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"incline": "up"}) + if got != nil { + t.Errorf("non-percentage incline alone should return nil, got %+v", got) + } + }) } func TestMapTagsToProfile_Elevator(t *testing.T) { diff --git a/pkg/models/README.md b/pkg/models/README.md index 4614cb2..d4bb50a 100644 --- a/pkg/models/README.md +++ b/pkg/models/README.md @@ -25,9 +25,11 @@ erDiagram `AccessibilityProfile` has one named, independently-optional field per component type — `Entrance`, `Pathways`, `Restroom`, `Parking`, `Elevator` — rather than an array of generically-typed components. Each is a pointer; a `nil` component means no data was submitted for it, not "unknown/inaccessible." There is no top-level or per-component status field — component structs hold typed facts directly (e.g. `EntranceProps.Width`, `RestroomProps.HasGrabRails`). +Measurement fields (`width`, `slope_percent`, `door_width`, `turning_radius`, `toilet_seat_height`, `distance_to_entrance`, `depth`) are `*AccessibilityLevel` (`good`/`limited`/`no`), not raw numbers — submitters rate the field directly rather than measuring it. Each field's doc comment cites the standard (SIA 500 or ADA) its bands are derived from. + `SourceReports` is a separate array of `SourceReport{Source, Value, RecordedAt}` — raw opinions from external sources (e.g. OSM's `wheelchair=yes` tag) stored verbatim, uninterpreted. Clients decide how much to trust each source. -`AuditFlags` on each component are string facts computed by `internal/a11y` on every write (e.g. `"narrow width (0.8m required)"`). They describe physical properties of that component and are surfaced to clients as-is. The API never uses them to decide a place's accessibility status, and no write is ever rejected because of them — that judgment is left to client logic applied against the user's profile. +`AuditFlags` on each component are string facts computed by `internal/a11y` on every write (e.g. `"narrow width"` when a measurement field is rated `no`). They describe physical properties of that component and are surfaced to clients as-is. The API never uses them to decide a place's accessibility status, and no write is ever rejected because of them — that judgment is left to client logic applied against the user's profile. `IsInherited` and `SourceID` on a component are set at read time by `internal/a11y.ComputeEffectiveProfile` when the component originates from a parent place. They are not persisted. diff --git a/pkg/models/accessibility.go b/pkg/models/accessibility.go index 14262ad..8c72379 100644 --- a/pkg/models/accessibility.go +++ b/pkg/models/accessibility.go @@ -34,8 +34,8 @@ type AccessibilityProfile struct { // SourceReport is a raw opinion about accessibility from a named external source. // Records what a source said without interpreting it. Clients decide trust level. type SourceReport struct { - Source string `json:"source"` // e.g. "osm", "wheelmap", "user" - Value string `json:"value"` // raw: "yes", "limited", "no" + Source string `json:"source"` // e.g. "osm", "wheelmap", "user" + Value string `json:"value"` // raw: "yes", "limited", "no" RecordedAt time.Time `json:"recorded_at"` } @@ -53,6 +53,14 @@ const ( DoorNone DoorType = "none" ) +type AccessibilityLevel string + +const ( + LevelGood AccessibilityLevel = "good" + LevelLimited AccessibilityLevel = "limited" + LevelNo AccessibilityLevel = "no" +) + type SurfaceType string const ( @@ -67,84 +75,93 @@ const ( ) type DoorProps struct { - Type DoorType `json:"type,omitempty"` - Width *float64 `json:"width,omitempty"` // metres, clear opening + Type DoorType `json:"type,omitempty"` + // Width is the clear opening. Good >=0.80m, limited 0.70-0.80m, no <0.70m (SIA 500). + Width *AccessibilityLevel `json:"width,omitempty"` } -// EntranceProps. All dimensions in metres. AuditFlags are computed on write. +// EntranceProps. AuditFlags are computed on write. // IsInherited and SourceID are set by ComputeEffectiveProfile at read time, never stored. type EntranceProps struct { - IsLevel *bool `json:"is_level,omitempty"` - HasFixedRamp *bool `json:"has_fixed_ramp,omitempty"` - HasRemovableRamp *bool `json:"has_removable_ramp,omitempty"` - SlopePercent *float64 `json:"slope_percent,omitempty"` - Width *float64 `json:"width,omitempty"` - Door *DoorProps `json:"door,omitempty"` - HasIntercom *bool `json:"has_intercom,omitempty"` - AuditFlags []string `json:"audit_flags,omitempty"` - IsInherited bool `json:"is_inherited,omitempty"` - SourceID string `json:"source_id,omitempty"` + IsLevel *bool `json:"is_level,omitempty"` + HasFixedRamp *bool `json:"has_fixed_ramp,omitempty"` + HasRemovableRamp *bool `json:"has_removable_ramp,omitempty"` + // SlopePercent: good <=6%, limited 6-8.33%, no >8.33% (ADA). + SlopePercent *AccessibilityLevel `json:"slope_percent,omitempty"` + // Width is the clear opening. Good >=0.80m, limited 0.70-0.80m, no <0.70m (SIA 500). + Width *AccessibilityLevel `json:"width,omitempty"` + Door *DoorProps `json:"door,omitempty"` + HasIntercom *bool `json:"has_intercom,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } func (p *EntranceProps) Scan(value interface{}) error { return scanJSONB(p, value) } func (p EntranceProps) Value() (driver.Value, error) { return marshalJSONB(p) } type PathwayProps struct { - Width *float64 `json:"width,omitempty"` // narrowest passage in metres - Surface SurfaceType `json:"surface,omitempty"` - IsKerbstoneFree *bool `json:"is_kerbstone_free,omitempty"` - HasSteps *bool `json:"has_steps,omitempty"` - AuditFlags []string `json:"audit_flags,omitempty"` - IsInherited bool `json:"is_inherited,omitempty"` - SourceID string `json:"source_id,omitempty"` + // Width is the narrowest passage. Good >=0.90m, limited 0.80-0.90m, no <0.80m (ADA). + Width *AccessibilityLevel `json:"width,omitempty"` + Surface SurfaceType `json:"surface,omitempty"` + IsKerbstoneFree *bool `json:"is_kerbstone_free,omitempty"` + HasSteps *bool `json:"has_steps,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } func (p *PathwayProps) Scan(value interface{}) error { return scanJSONB(p, value) } func (p PathwayProps) Value() (driver.Value, error) { return marshalJSONB(p) } -// RestroomProps. All dimensions in metres. type RestroomProps struct { - IsAccessible *bool `json:"is_accessible,omitempty"` - DoorWidth *float64 `json:"door_width,omitempty"` - TurningRadius *float64 `json:"turning_radius,omitempty"` - HasGrabRails *bool `json:"has_grab_rails,omitempty"` - HasRollInShower *bool `json:"has_roll_in_shower,omitempty"` - ToiletSeatHeight *float64 `json:"toilet_seat_height,omitempty"` - HasEmergencyPull *bool `json:"has_emergency_pull,omitempty"` - HasChangingTable *bool `json:"has_changing_table,omitempty"` - AuditFlags []string `json:"audit_flags,omitempty"` - IsInherited bool `json:"is_inherited,omitempty"` - SourceID string `json:"source_id,omitempty"` + IsAccessible *bool `json:"is_accessible,omitempty"` + // DoorWidth: good >=0.80m, limited 0.70-0.80m, no <0.70m (SIA 500). + DoorWidth *AccessibilityLevel `json:"door_width,omitempty"` + // TurningRadius: good >=1.50m, limited 1.20-1.50m, no <1.20m (ADA). + TurningRadius *AccessibilityLevel `json:"turning_radius,omitempty"` + HasGrabRails *bool `json:"has_grab_rails,omitempty"` + HasRollInShower *bool `json:"has_roll_in_shower,omitempty"` + // ToiletSeatHeight: good 0.43-0.48m, limited 0.40-0.43m or 0.48-0.50m, no outside that (ADA). + ToiletSeatHeight *AccessibilityLevel `json:"toilet_seat_height,omitempty"` + HasEmergencyPull *bool `json:"has_emergency_pull,omitempty"` + HasChangingTable *bool `json:"has_changing_table,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } func (p *RestroomProps) Scan(value interface{}) error { return scanJSONB(p, value) } func (p RestroomProps) Value() (driver.Value, error) { return marshalJSONB(p) } -// ParkingProps. DistanceToEntrance and Width in metres. type ParkingProps struct { - HasDisabledSpaces *bool `json:"has_disabled_spaces,omitempty"` - Count *int `json:"count,omitempty"` - DistanceToEntrance *float64 `json:"distance_to_entrance,omitempty"` - Width *float64 `json:"width,omitempty"` - HasDedicatedSignage *bool `json:"has_dedicated_signage,omitempty"` - AuditFlags []string `json:"audit_flags,omitempty"` - IsInherited bool `json:"is_inherited,omitempty"` - SourceID string `json:"source_id,omitempty"` + HasDisabledSpaces *bool `json:"has_disabled_spaces,omitempty"` + Count *int `json:"count,omitempty"` + // DistanceToEntrance: good <=25m, limited 25-50m, no >50m (ADA specifies no maximum; nearest available figure used). + DistanceToEntrance *AccessibilityLevel `json:"distance_to_entrance,omitempty"` + // Width is the parking space width. Good >=2.40m, limited 2.00-2.40m, no <2.00m (ADA). + Width *AccessibilityLevel `json:"width,omitempty"` + HasDedicatedSignage *bool `json:"has_dedicated_signage,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } func (p *ParkingProps) Scan(value interface{}) error { return scanJSONB(p, value) } func (p ParkingProps) Value() (driver.Value, error) { return marshalJSONB(p) } -// ElevatorProps. Width, Depth, and DoorWidth in metres. type ElevatorProps struct { - Width *float64 `json:"width,omitempty"` - Depth *float64 `json:"depth,omitempty"` - DoorWidth *float64 `json:"door_width,omitempty"` - HasBraille *bool `json:"has_braille,omitempty"` - HasAudio *bool `json:"has_audio,omitempty"` - AuditFlags []string `json:"audit_flags,omitempty"` - IsInherited bool `json:"is_inherited,omitempty"` - SourceID string `json:"source_id,omitempty"` + // Width: good >=1.70m, limited 1.50-1.70m, no <1.50m (ADA). + Width *AccessibilityLevel `json:"width,omitempty"` + // Depth: good >=1.30m, limited 1.10-1.30m, no <1.10m (ADA). + Depth *AccessibilityLevel `json:"depth,omitempty"` + // DoorWidth: good >=0.90m, limited 0.80-0.90m, no <0.80m (ADA). + DoorWidth *AccessibilityLevel `json:"door_width,omitempty"` + HasBraille *bool `json:"has_braille,omitempty"` + HasAudio *bool `json:"has_audio,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } func (p *ElevatorProps) Scan(value interface{}) error { return scanJSONB(p, value) }