{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Rail Replacement Location (RRL) JSON Schema",
  "description": "JSON form of the Rail Replacement Location dataset. Mirrors rrl-schema-v1_0.xsd, which in turn mirrors NaPTAN 2.4 for every element that has a NaPTAN equivalent. Both forms carry language on a plain 'lang' key or attribute rather than NaPTAN's xml:lang, so that the XSD needs no external import; that is a deliberate deviation from NaPTAN. Numeric fields are JSON numbers rather than strings. Element ordering is not enforceable in JSON; the converters emit keys in the XSD's sequence order by convention. Two constraints in the XSD cannot be expressed here and must be enforced by the publishing pipeline: AtcoCode uniqueness across StopPoints, and calendar validity of date-time values.",
  "type": "object",
  "required": [
    "RailReplacementLocation"
  ],
  "additionalProperties": false,
  "properties": {
    "RailReplacementLocation": {
      "$ref": "#/$defs/RailReplacementLocation"
    }
  },
  "$defs": {
    "RailReplacementLocation": {
      "type": "object",
      "description": "Document root. Corresponds to the RailReplacementLocation element and its attributes.",
      "required": [
        "CreationDateTime",
        "ModificationDateTime",
        "Modification",
        "RevisionNumber",
        "FileName",
        "Version",
        "StopPoints"
      ],
      "additionalProperties": false,
      "properties": {
        "CreationDateTime": {
          "$ref": "#/$defs/DateTime"
        },
        "ModificationDateTime": {
          "$ref": "#/$defs/DateTime"
        },
        "Modification": {
          "$ref": "#/$defs/Modification"
        },
        "RevisionNumber": {
          "$ref": "#/$defs/RevisionNumber"
        },
        "FileName": {
          "type": "string",
          "minLength": 1
        },
        "Version": {
          "const": "1.0",
          "description": "Version of the RRL JSON output structure."
        },
        "StopPoints": {
          "type": "array",
          "minItems": 1,
          "items": {
            "$ref": "#/$defs/StopPoint"
          },
          "description": "AtcoCode must be unique across the array. JSON Schema cannot express a cross-item key constraint, so a JSON document with duplicate AtcoCodes will pass this schema while the equivalent XML is rejected by the XSD's StopPointKey. Uniqueness must be enforced by the publishing pipeline."
        }
      }
    },
    "StopPoint": {
      "type": "object",
      "required": [
        "CreationDateTime",
        "Modification",
        "RevisionNumber",
        "AtcoCode",
        "Descriptor",
        "Place",
        "StopClassification"
      ],
      "additionalProperties": false,
      "properties": {
        "CreationDateTime": {
          "$ref": "#/$defs/DateTime"
        },
        "ModificationDateTime": {
          "$ref": "#/$defs/DateTime"
        },
        "Modification": {
          "$ref": "#/$defs/Modification"
        },
        "RevisionNumber": {
          "$ref": "#/$defs/RevisionNumber"
        },
        "AtcoCode": {
          "$ref": "#/$defs/RrlAtcoCode"
        },
        "Descriptor": {
          "$ref": "#/$defs/Descriptor"
        },
        "AlternativeDescriptors": {
          "type": "array",
          "minItems": 1,
          "items": {
            "$ref": "#/$defs/AlternativeDescriptor"
          },
          "description": "Descriptors in other languages. At least one entry if the key is present; omit the key entirely rather than supplying an empty array. The XSD requires at least one Descriptor inside AlternativeDescriptors, so an empty array here would produce XML the XSD rejects."
        },
        "Place": {
          "$ref": "#/$defs/Place"
        },
        "StopClassification": {
          "$ref": "#/$defs/StopClassification"
        }
      }
    },
    "Descriptor": {
      "type": "object",
      "description": "Primary descriptor. NaPTAN also allows ShortCommonName, Landmark, Crossing and Indicator here; RRL omits them.",
      "required": [
        "CommonName",
        "Description"
      ],
      "additionalProperties": false,
      "properties": {
        "lang": {
          "$ref": "#/$defs/LanguageCode",
          "description": "Optional on the primary descriptor, matching the optional xml:lang in the XSD."
        },
        "CommonName": {
          "$ref": "#/$defs/PlaceName"
        },
        "Street": {
          "$ref": "#/$defs/PlaceName"
        },
        "Description": {
          "$ref": "#/$defs/FreeText"
        }
      }
    },
    "AlternativeDescriptor": {
      "type": "object",
      "description": "A descriptor in another language. lang, CommonName and Description are required; Street is optional.",
      "required": [
        "lang",
        "CommonName",
        "Description"
      ],
      "additionalProperties": false,
      "properties": {
        "lang": {
          "$ref": "#/$defs/LanguageCode"
        },
        "CommonName": {
          "$ref": "#/$defs/PlaceName"
        },
        "Street": {
          "$ref": "#/$defs/PlaceName"
        },
        "Description": {
          "$ref": "#/$defs/FreeText"
        }
      }
    },
    "Place": {
      "type": "object",
      "description": "Where the stop is. Location is optional, which is a deliberate deviation from NaPTAN: a minority of published records carry a locality but no surveyed coordinates.",
      "required": [
        "NptgLocalityRef"
      ],
      "additionalProperties": false,
      "properties": {
        "NptgLocalityRef": {
          "$ref": "#/$defs/NptgLocalityCode"
        },
        "Location": {
          "$ref": "#/$defs/Location"
        }
      }
    },
    "Location": {
      "type": "object",
      "description": "Position of the stop. The Translation wrapper is retained from the XSD, where it is the NaPTAN branch that carries grid and WGS84 coordinates together.",
      "required": [
        "Translation"
      ],
      "additionalProperties": false,
      "properties": {
        "Translation": {
          "$ref": "#/$defs/Translation"
        }
      }
    },
    "Translation": {
      "type": "object",
      "description": "Grid and WGS84 coordinates for the same point. All four coordinate values are required together.",
      "required": [
        "Easting",
        "Northing",
        "Longitude",
        "Latitude"
      ],
      "additionalProperties": false,
      "properties": {
        "GridType": {
          "const": "UKOS",
          "description": "Optional with a default of UKOS, as in NaPTAN. NaPTAN also allows IrishOS; RRL does not."
        },
        "Easting": {
          "type": "integer",
          "minimum": 0,
          "maximum": 999999
        },
        "Northing": {
          "type": "integer",
          "minimum": 0,
          "maximum": 1999999
        },
        "Longitude": {
          "type": "number",
          "minimum": -180,
          "maximum": 180
        },
        "Latitude": {
          "type": "number",
          "minimum": -90,
          "maximum": 90
        }
      }
    },
    "StopClassification": {
      "type": "object",
      "required": [
        "StopType",
        "RailReplacement"
      ],
      "additionalProperties": false,
      "properties": {
        "StopType": {
          "const": "RRL",
          "description": "Classification of stop point, only ever RRL. Not a NaPTAN 2.4 stop type."
        },
        "RailReplacement": {
          "$ref": "#/$defs/RailReplacement"
        }
      }
    },
    "RailReplacement": {
      "type": "object",
      "description": "Rail replacement details. This section has no NaPTAN equivalent; it replaces the OnStreet/OffStreet choice NaPTAN carries at this position.",
      "required": [
        "CrsRef",
        "SFOCode"
      ],
      "additionalProperties": false,
      "properties": {
        "CrsRef": {
          "type": "string",
          "pattern": "^[A-Z]{3}$",
          "description": "Three character CRS code identifying the associated railway station. NaPTAN carries a CrsRef of its own under StopClassification/OffStreet/Rail/AnnotatedRailRef for a different purpose; this one keeps its own position but shares the NaPTAN rule."
        },
        "SFOCode": {
          "type": "string",
          "pattern": "^[A-Za-z0-9]{2}$",
          "description": "Station Facility Owner code, identifying the Train Operating Company responsible for the station. Named to match the SFOCode column of the published CSV. The XSD applies the same [A-Za-z0-9]{2} pattern; without it, xs:NMTOKEN would admit punctuation such as 'G-'."
        },
        "MarkedStopAtcoCode": {
          "$ref": "#/$defs/AtcoCode",
          "description": "Reference to an existing NaPTAN stop, where the rail replacement location uses a permanent bus stop. When absent, the location is a rail replacement only stop. Uses the general NaPTAN ATCO rule, not the 911 rule, because it names a stop in its own administrative area."
        }
      }
    },
    "RrlAtcoCode": {
      "type": "string",
      "pattern": "^9110[A-Z]{3}[0-9]{1,5}$",
      "description": "ATCO code for a rail replacement location: 911 + 0 + CRS code + sequence number, for example 9110OCK0, 9110OCK1, 9110AMR0. The sequence number starts at zero for the first location at a station and increments for each further one. The 1 to 5 digit limit is inherited from the general NaPTAN ATCO rule, which allows at most eight characters after the separator, three of which the CRS code takes."
    },
    "AtcoCode": {
      "type": "string",
      "pattern": "^[0-9]{3}[0-1][A-Za-z0-9]{1,8}$",
      "description": "General NaPTAN ATCO code: administrative area code, separator, then a locally assigned identifier."
    },
    "NptgLocalityCode": {
      "type": "string",
      "pattern": "^[EN][0S][0-9]{6}$",
      "description": "NPTG locality identifier. ab999999, where a is E or N, b is 0 or S, 9 is numeric."
    },
    "PlaceName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 48,
      "description": "A natural language place name. Mirrors NaPTAN's PopulatedPlaceNameType: at least one character, at most 48, and free of the reserved characters , [ ] { } ? $ £ % ^ = @ # ; : - a qualifier belongs in its own field rather than embedded with punctuation. A value of only whitespace is rejected: minLength alone counts spaces, so a separate check is required.",
      "allOf": [
        {
          "pattern": "^[^,\\[\\]{}?$£%^=@#;:]+$",
          "description": "Free of the NaPTAN reserved characters."
        },
        {
          "pattern": "\\S",
          "description": "Must contain at least one non-whitespace character."
        }
      ]
    },
    "FreeText": {
      "type": "string",
      "minLength": 1,
      "description": "A natural language string. Mirrors NaPTAN's NaturalLanguageStringStructure: at least one character, no upper bound and no character restriction. Must also contain at least one non-whitespace character.",
      "pattern": "\\S"
    },
    "LanguageCode": {
      "type": "string",
      "pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$",
      "description": "Language tag. Corresponds to the lang attribute in the XSD, which uses a local unqualified attribute rather than NaPTAN's xml:lang so that the schema stays self-contained. Currently en and cy are in use. Deliberately not restricted to those two, so that further languages need no schema change. The XSD applies the same pattern."
    },
    "DateTime": {
      "type": "string",
      "pattern": "^-?[0-9]{4,}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})?$",
      "description": "An xs:dateTime value. A pattern is used rather than format: date-time because RFC 3339 requires a timezone offset while xs:dateTime does not, and the published data omits it. Note that a regular expression cannot check calendar validity: 2026-13-45T99:99:99 satisfies this pattern but is rejected by the XSD's xs:dateTime. Validate dates in the pipeline."
    },
    "Modification": {
      "enum": [
        "new",
        "revise",
        "delete"
      ],
      "description": "Nature of the last modification. A restriction of the NaPTAN enumeration, which also allows delta and archive. Note the published CSV ships these capitalised (New); the converters lower-case them to the NaPTAN spellings."
    },
    "RevisionNumber": {
      "type": "integer",
      "minimum": 0,
      "description": "Increased by one each time the record is modified. A JSON number, not a string."
    }
  }
}
