Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 16 additions & 48 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions cmd/api/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
Expand Down
22 changes: 12 additions & 10 deletions internal/a11y/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 43 additions & 17 deletions internal/a11y/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -139,18 +165,18 @@ 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
}

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 {
Expand All @@ -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 {
Expand Down
Loading
Loading