diff --git a/PYDANTIC_GUIDE.md b/PYDANTIC_GUIDE.md index de913043b..3f502c9e5 100644 --- a/PYDANTIC_GUIDE.md +++ b/PYDANTIC_GUIDE.md @@ -41,24 +41,26 @@ from enum import Enum from pydantic import BaseModel, Field # Overture core models -from overture.schema.core import Feature -from overture.schema.core.models import StrictBaseModel -from overture.schema.core.geometry import Geometry, GeometryType, GeometryTypeConstraint +from overture.schema.core import OvertureFeature +from overture.schema.system.primitive import Geometry, GeometryType, GeometryTypeConstraint # Validation system -from overture.schema.validation import UniqueItemsConstraint +from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import no_extra_fields # Common types +from overture.schema.system.string import ( + CountryCodeAlpha2, + NoWhitespaceString, + StrippedString, +) from overture.schema.core.types import ( ConfidenceScore, - CountryCode, LanguageTag, - NoWhitespaceString, - TrimmedString ) # Numeric primitives (use these instead of int/float) -from overture.schema.core.primitives.numeric import ( +from overture.schema.system.primitive import ( int8, int32, int64, uint8, uint16, uint32, float32, float64 @@ -69,11 +71,12 @@ from overture.schema.core.primitives.numeric import ( ```python from typing import Annotated -from pydantic import Field -from overture.schema.core.models import StrictBaseModel -from overture.schema.core.primitives.numeric import int8, float64 +from pydantic import BaseModel, Field +from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.primitive import int8, float64 -class MyCustomType(StrictBaseModel): +@no_extra_fields +class MyCustomType(BaseModel): """Brief description of what this represents.""" # Required fields (no default value) @@ -99,10 +102,10 @@ class MyCustomType(StrictBaseModel): ```python from typing import Annotated, Literal from pydantic import Field -from overture.schema.core import Feature -from overture.schema.core.geometry import Geometry, GeometryType, GeometryTypeConstraint +from overture.schema.core import OvertureFeature +from overture.schema.system.primitive import Geometry, GeometryType, GeometryTypeConstraint -class MyFeature(Feature[Literal["my_theme"], Literal["my_type"]]): +class MyFeature(OvertureFeature[Literal["my_theme"], Literal["my_type"]]): """Description of what this feature represents.""" # Geometry with constraints @@ -130,14 +133,15 @@ Pydantic models are Python classes that define data structures and their constra **What is a "base class"?** A base class defines common fields and behaviors that other classes can reuse. Think of it like a slide template - you create one layout, then make specific slides that use that structure. -**What is "inheritance"?** Inheritance means one class automatically gets all the fields and behaviors from another class. If Building inherits from Feature, it automatically gets all of Feature's fields (like `id`, `geometry`) plus any new fields you add to Building (like `height`). When multiple parent classes have the same field name, Python uses a [specific order](https://docs.python.org/3/tutorial/classes.html#multiple-inheritance) to determine which one takes precedence. +**What is "inheritance"?** Inheritance means one class automatically gets all the fields and behaviors from another class. If Building inherits from OvertureFeature, it automatically gets all of Feature's fields (like `id`, `geometry`) plus any new fields you add to Building (like `height`). When multiple parent classes have the same field name, Python uses a [specific order](https://docs.python.org/3/tutorial/classes.html#multiple-inheritance) to determine which one takes precedence. -**StrictBaseModel** - Use for structured data components that should reject unknown fields: +**@no_extra_fields** - Use for structured data components that should reject unknown fields: ```python -from overture.schema.core.models import StrictBaseModel +from overture.schema.system.model_constraint import no_extra_fields -class Address(StrictBaseModel): +@no_extra_fields +class Address(BaseModel): """A postal address - no extra fields allowed.""" street: str city: str @@ -145,20 +149,20 @@ class Address(StrictBaseModel): # Any field not defined here will cause validation to fail ``` -**Feature[ThemeT, TypeT]** - A generic base class for all geospatial features with typed theme and type parameters: +**OvertureFeature[ThemeT, TypeT]** - A generic base class for all geospatial features with typed theme and type parameters: ```python from typing import Literal -from overture.schema.core import Feature -from overture.schema.core.primitives import float64 +from overture.schema.core import OvertureFeature +from overture.schema.system.primitive import float64 -class Building(Feature[Literal["buildings"], Literal["building"]]): +class Building(OvertureFeature[Literal["buildings"], Literal["building"]]): """A building feature with strongly-typed theme and type.""" # Inherits: id, theme, type, geometry, bbox, version, sources height: float64 | None = None ``` -**What does "generic" mean?** The `Feature[ThemeT, TypeT]` syntax makes Feature a "generic" class - think of it like a template that can be customized with specific values. The square brackets `[]` contain "type parameters" that specify exactly what theme and type this feature represents. +**What does "generic" mean?** The `OvertureFeature[ThemeT, TypeT]` syntax makes OvertureFeature a "generic" class - think of it like a template that can be customized with specific values. The square brackets `[]` contain "type parameters" that specify exactly what theme and type this feature represents. **What are ThemeT and TypeT?** These are placeholders for specific text values: @@ -167,7 +171,7 @@ class Building(Feature[Literal["buildings"], Literal["building"]]): **What is `Literal`?** `Literal` means the field must be exactly one of the specified values - nothing else is allowed. So `Literal["buildings"]` means this theme can only be "buildings", not any other string. -By specifying `Feature[Literal["buildings"], Literal["building"]]`, you're saying "this is a Feature that must have theme='buildings' and type='building'" - no other values are allowed. This prevents mistakes like accidentally creating a building with theme="places". +By specifying `OvertureFeature[Literal["buildings"], Literal["building"]]`, you're saying "this is a Feature that must have theme='buildings' and type='building'" - no other values are allowed. This prevents mistakes like accidentally creating a building with theme="places". #### Inheritance Patterns @@ -175,11 +179,12 @@ By specifying `Feature[Literal["buildings"], Literal["building"]]`, you're sayin ```python from typing import Literal -from overture.schema.core import Feature -from overture.schema.core.models import Named, Stacked -from overture.schema.core.primitives import float64 +from overture.schema.core import OvertureFeature +from overture.schema.core.models import Stacked +from overture.schema.core.names import Named +from overture.schema.system.primitives import float64 -class Building(Feature[Literal["buildings"], Literal["building"]], Named, Stacked): +class Building(OvertureFeature[Literal["buildings"], Literal["building"]], Named, Stacked): # Gets fields from Feature: id, theme, type, geometry, etc. # Gets fields from Named: names # Gets fields from Stacked: level @@ -195,7 +200,7 @@ Sometimes you need a field name that conflicts with Python keywords or conventio from typing import Annotated from pydantic import Field -class Building(Feature): +class Building(OvertureFeature): # Use class_ in Python code, but "class" in the actual data class_: Annotated[str | None, Field(alias="class")] = None @@ -211,7 +216,7 @@ A common example is `class_` with `Field(alias="class")` since "class" is a Pyth #### Required vs Optional Fields ```python -class Building(Feature): +class Building(OvertureFeature): # Required field (no default value) geometry: Geometry @@ -261,13 +266,15 @@ Keep the schema separate from business logic. The schema describes the shape of **Always use specific numeric types instead of Python's generic `int`/`float`:** ```python -from overture.schema.core.primitives.numeric import ( +from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.primitive import ( int8, int32, int64, # Signed integers uint8, uint16, uint32, # Unsigned integers float32, float64 # Floating point ) -class MyModel(StrictBaseModel): +@no_extra_fields +class MyModel(BaseModel): # Signed integers with specific ranges level: int8 | None = None # -128 to 127 year: int32 | None = None # -2,147,483,648 to 2,147,483,647 @@ -311,7 +318,7 @@ Union types allow a field to accept multiple different types. The `|` symbol mea ```python from typing import Literal -class Building(Feature): +class Building(OvertureFeature): # This field can be either a string OR None (most common union) name: str | None = None @@ -379,7 +386,7 @@ Use Pydantic's `Field()` function to add constraints and descriptions: from typing import Annotated from pydantic import Field -class Building(Feature): +class Building(OvertureFeature): # Range constraints height: Annotated[ float64 | None, @@ -403,7 +410,7 @@ class Building(Feature): **String constraints:** ```python -class Place(Feature): +class Place(OvertureFeature): # Length constraints name: Annotated[ str | None, @@ -439,9 +446,9 @@ class Building(Feature): #### List Constraints ```python -from overture.schema.validation import UniqueItemsConstraint +from overture.schema.system.field_constraint import UniqueItemsConstraint -class Building(Feature): +class Building(OvertureFeature): # List with size and uniqueness constraints categories: Annotated[ list[str] | None, @@ -491,7 +498,7 @@ class BuildingClass(str, Enum): CIVIC = "civic" # Usage in a model -class Building(Feature): +class Building(OvertureFeature): class_: Annotated[BuildingClass | None, Field(alias="class")] = None ``` @@ -499,6 +506,8 @@ class Building(Feature): Add documentation to describe what the enum and its values mean. In Python, you do this with **docstrings** - text enclosed in triple quotes `"""` that describes what something does: +TODO: DocumentedEnum + ```python class VehicleType(str, Enum): """Types of vehicles for transportation.""" @@ -530,11 +539,10 @@ The fundamental pattern is a direct reference where one feature "points to" anot ```python from typing import Annotated, Literal from pydantic import Field -from overture.schema.core import Feature -from overture.schema.core.ref import Reference, Relationship -from overture.schema.core.types import Id +from overture.schema.core import OvertureFeature +from overture.schema.system.ref import Id, Reference, Relationship -class DivisionArea(Feature[Literal["divisions"], Literal["division_area"]]): +class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]): """Area polygon that belongs to a division.""" # Required reference - every division area must belong to a division @@ -571,7 +579,7 @@ When the relationship itself needs to store information, create a dedicated feat - "Admin Area X has City Center Y as its primary center since 2010 with 85% confidence" - the relationship has properties (`type=primary`, `date=2010`, `confidence=85%`) ```python -class AdminCityCenterAssociation(Feature[Literal["associations"], Literal["admin_city_center"]]): +class AdminCityCenterAssociation(OvertureFeature[Literal["associations"], Literal["admin_city_center"]]): """Describes how an administrative area relates to a city center.""" # The two things being connected @@ -597,7 +605,7 @@ This focuses on the core concept: when relationships carry data, they become fea When a feature needs to reference multiple other features, use a list of references: ```python -class Route(Feature[Literal["transportation"], Literal["route"]]): +class Route(OvertureFeature[Literal["transportation"], Literal["route"]]): """A transportation route that passes through multiple segments.""" segment_ids: Annotated[ @@ -607,7 +615,7 @@ class Route(Feature[Literal["transportation"], Literal["route"]]): Reference(Relationship.CONNECTS_TO, TransportationSegment) # All IDs reference segments ] -class Building(Feature[Literal["buildings"], Literal["building"]]): +class Building(OvertureFeature[Literal["buildings"], Literal["building"]]): """A building that may contain multiple building parts.""" part_ids: Annotated[ @@ -657,7 +665,7 @@ class AdminCityCenterAssociation(Feature[...]): ```python # Segments connect to connectors (intersection points) -class Segment(Feature[Literal["transportation"], Literal["segment"]]): +class Segment(OvertureFeature[Literal["transportation"], Literal["segment"]]): from_connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, Connector)] to_connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, Connector)] @@ -674,11 +682,11 @@ class Route(Feature[Literal["transportation"], Literal["route"]]): ```python # Division areas belong to divisions -class DivisionArea(Feature[Literal["divisions"], Literal["division_area"]]): +class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]): division_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Division)] # Places belong to administrative areas -class Place(Feature[Literal["places"], Literal["place"]]): +class Place(OvertureFeature[Literal["places"], Literal["place"]]): admin_area_id: Annotated[Id | None, Reference(Relationship.BELONGS_TO, AdminArea)] = None ``` @@ -686,11 +694,11 @@ class Place(Feature[Literal["places"], Literal["place"]]): ```python # Building parts belong to buildings -class BuildingPart(Feature[Literal["buildings"], Literal["building_part"]]): +class BuildingPart(OvertureFeature[Literal["buildings"], Literal["building_part"]]): building_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Building)] # Buildings can reference their address -class Building(Feature[Literal["buildings"], Literal["building"]]): +class Building(OvertureFeature[Literal["buildings"], Literal["building"]]): address_id: Annotated[Id | None, Reference(Relationship.CONNECTS_TO, Address)] = None ``` @@ -701,10 +709,10 @@ class Building(Feature[Literal["buildings"], Literal["building"]]): ```python from typing import Annotated, Literal from pydantic import Field -from overture.schema.core import Feature +from overture.schema.core import OvertureFeature # Base class with common fields -class TransportationSegment(Feature[Literal["transportation"], Literal["segment"]]): +class TransportationSegment(OvertureFeature[Literal["transportation"], Literal["segment"]]): subtype: Subtype # This is the discriminator field # ... common fields for all segments @@ -749,7 +757,7 @@ from abc import ABC, abstractmethod from typing import Annotated, Literal from pydantic import Field -class TransportationSegment(Feature[Literal["transportation"], Literal["segment"]], ABC): +class TransportationSegment(OvertureFeature[Literal["transportation"], Literal["segment"]], ABC): """Abstract base - cannot be instantiated directly.""" subtype: Subtype # Discriminator field @@ -801,9 +809,10 @@ Instead of making classes abstract, we use **entry point registration** where on ```python from typing import Annotated -from pydantic import Field +from pydantic import BaseModel, Field -class Names(StrictBaseModel): +@no_extra_fields +class Names(BaseModel): primary: str # Keys (strings) must match a language tag pattern, values are strings @@ -847,11 +856,12 @@ from typing import Annotated from pydantic import Field # Each item has its own field validation -class HierarchyItem(StrictBaseModel): +@no_extra_fields +class HierarchyItem(BaseModel): division_id: str name: str -class Division(Feature): +class Division(OvertureFeature): # Nested list validation: outer list AND inner lists both have length constraints hierarchies: Annotated[ list[ # Outer list @@ -889,7 +899,7 @@ This creates validation at three levels: ```python from typing import NewType, Annotated -from pydantic import Field +from pydantic import BaseModel, Field # Create distinct types for different kinds of strings SegmentId = NewType("SegmentId", str) # IDs are strings, but distinct @@ -901,7 +911,8 @@ EmailList = NewType("EmailList", Annotated[ Field(min_length=1, description="List of email addresses") ]) -class Contact(StrictBaseModel): +@no_extra_fields +class Contact(BaseModel): # Clear, self-documenting field types id: SegmentId # Can't accidentally use a CountryCode here country: CountryCode # Can't accidentally use a SegmentId here @@ -927,7 +938,7 @@ Organize code by scope and avoid circular imports: **Cross-theme shared**: `overture-schema-core` package -- Used by multiple themes (e.g., `LanguageTag`, `CountryCode`, `Feature`) +- Used by multiple themes (e.g., `LanguageTag`, `CountryCode`, `OvertureFeature`) **Theme-level shared**: Theme package root (e.g., `overture-schema-transportation-theme/src/overture/schema/transportation/`) @@ -954,9 +965,9 @@ from enum import Enum from pydantic import BaseModel, ConfigDict, Field # Cross-theme imports -from overture.schema.core import Feature -from overture.schema.core.models import StrictBaseModel -from overture.schema.validation import UniqueItemsConstraint +from overture.schema.core import OvertureFeature +from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import no_extra_fields # Local imports last from .enums import SegmentType @@ -967,7 +978,7 @@ from .types import SegmentId, LaneWidth # Only non-model type aliases #### Why Not Use @field_validator or @model_validator? -This project uses a custom validation system that generates better JSON Schema output and supports code generation (without additional work, `@field_validator` and `@model_validator` don't make their constraints discoverable). Always use constraints from `overture.schema.validation` instead of using Pydantic validation decorators: +This project uses a custom validation system that generates better JSON Schema output and supports code generation (without additional work, `@field_validator` and `@model_validator` don't make their constraints discoverable). Always use constraints from `overture.schema.system` instead of using Pydantic validation decorators: ```python # Don't do this @@ -978,9 +989,9 @@ def validate_categories_unique(cls, v): return v # Do this instead -from overture.schema.validation import UniqueItemsConstraint +from overture.schema.system.field_constraint import UniqueItemsConstraint -class Building(Feature): +class Building(OvertureFeature): categories: Annotated[ list[str] | None, Field(min_length=1, description="Building categories"), @@ -1015,13 +1026,14 @@ properties: ```python # In overture-schema-core/src/overture/schema/core/models.py -class Address(StrictBaseModel): +@no_extra_fields +class Address(BaseModel): """A postal address.""" freeform: str | None = None locality: str | None = None # In overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py -class Building(Feature): +class Building(OvertureFeature): address: Address | None = None ``` @@ -1097,11 +1109,12 @@ JSON Schema containers become **mixin classes** in Pydantic that you inherit fro ```python models.py from typing import Annotated -from pydantic import Field -from overture.schema.core.models import StrictBaseModel -from overture.schema.core.primitives.numeric import int8, float64 +from pydantic import BaseModel, Field +from overture.schema.model_constraints import no_extra +from overture.schema.system.primitive import int8, float64 -class MyCustomType(StrictBaseModel): +@no_extra_fields +class MyCustomType(BaseModel): """Brief description of what this represents.""" # Required fields (no default value) @@ -1127,10 +1140,10 @@ class MyCustomType(StrictBaseModel): ```python models.py from typing import Annotated, Literal from pydantic import Field -from overture.schema.core import Feature -from overture.schema.core.geometry import Geometry, GeometryType, GeometryTypeConstraint +from overture.schema.core import OvertureFeature +from overture.schema.system.primitive import Geometry, GeometryType, GeometryTypeConstraint -class MyFeature(Feature[Literal["my_theme"], Literal["my_type"]]): +class MyFeature(OvertureFeature[Literal["my_theme"], Literal["my_type"]]): """Description of what this feature represents.""" # Geometry with constraints @@ -1161,11 +1174,12 @@ class MyEnum(str, Enum): ```python models.py from typing import Annotated -from pydantic import Field -from overture.schema.validation import UniqueItemsConstraint -from overture.schema.core.models import StrictBaseModel +from pydantic import BaseModel, Field +from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import no_extra_fields -class Contact(StrictBaseModel): +@no_extra_fields +class Contact(BaseModel): """Contact information with validation constraints.""" name: str @@ -1185,12 +1199,11 @@ class Contact(StrictBaseModel): ```python models.py from typing import Annotated, Literal from pydantic import Field -from overture.schema.core import Feature -from overture.schema.core.ref import Reference, Relationship -from overture.schema.core.types import Id -from overture.schema.core.primitives.numeric import float64 +from overture.schema.core import OvertureFeature +from overture.schema.system.primitive import float64 +from overture.schema.system.ref import Id, Reference, Relationship -class MyAssociation(Feature[Literal["associations"], Literal["my_association"]]): +class MyAssociation(OvertureFeature[Literal["associations"], Literal["my_association"]]): """Represents a relationship between two features with metadata.""" # References to the associated features @@ -1238,12 +1251,13 @@ connector_ids: list[Id] # References to multiple related features ```python # Non-feature model -class Address(StrictBaseModel): +@no_extra_fields +class Address(BaseModel): street: str city: str | None = None # Feature model -class Building(Feature[Literal["buildings"], Literal["building"]]): +class Building(OvertureFeature[Literal["buildings"], Literal["building"]]): geometry: Geometry height: float64 | None = None @@ -1269,14 +1283,13 @@ class Status(str, Enum): from typing import Annotated, Literal from enum import Enum from pydantic import Field -from overture.schema.core import Feature -from overture.schema.core.models import StrictBaseModel -from overture.schema.validation import UniqueItemsConstraint -from overture.schema.core.primitives.numeric import int32, float64 +from overture.schema.core import OvertureFeature +from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.primitive import int32, float64 # For associations and references -from overture.schema.core.ref import Reference, Relationship -from overture.schema.core.types import Id +from overture.schema.system.ref import Id, Reference, Relationship ``` #### Naming Conventions diff --git a/README.pydantic.md b/README.pydantic.md index 2d1eedafe..7bae5a7ff 100644 --- a/README.pydantic.md +++ b/README.pydantic.md @@ -90,7 +90,7 @@ This workspace contains the following packages: convenient usage - **`overture-schema-core`** - Base classes, geometry models, and common structures shared across all themes -- **`overture-schema-system`** - Foundational system of primitivef types and constraints +- **`overture-schema-system`** - Foundational system of primitive types and constraints ### Theme Packages diff --git a/packages/overture-schema-addresses-theme/README.md b/packages/overture-schema-addresses-theme/README.md index b8ad5fcbb..549456a55 100644 --- a/packages/overture-schema-addresses-theme/README.md +++ b/packages/overture-schema-addresses-theme/README.md @@ -1,4 +1,3 @@ # Overture Schema Addresses Theme -Shared structures and validation logic for Overture Maps addresses theme. -Contains address level utilities and validation patterns. +Feature types and shared components for the Overture Maps addresses theme. diff --git a/packages/overture-schema-addresses-theme/pyproject.toml b/packages/overture-schema-addresses-theme/pyproject.toml index db5329e47..ee4e47693 100644 --- a/packages/overture-schema-addresses-theme/pyproject.toml +++ b/packages/overture-schema-addresses-theme/pyproject.toml @@ -29,4 +29,4 @@ pythonpath = ["src"] testpaths = ["tests"] [project.entry-points."overture.models"] -"addresses.address" = "overture.schema.addresses.address.models:Address" +"addresses.address" = "overture.schema.addresses:Address" diff --git a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/__init__.py b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/__init__.py index f9bca4111..5f0522006 100644 --- a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/__init__.py +++ b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/__init__.py @@ -1,11 +1,10 @@ """Addresses theme. -Geographic address point features with flexible administrative levels and location data -structures. +Feature types and shared components for the Overture Maps addresses theme. """ __path__ = __import__("pkgutil").extend_path(__path__, __name__) -from .address import Address +from .address import Address, AddressLevel -__all__ = ["Address"] +__all__ = ["Address", "AddressLevel"] diff --git a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address.py b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address.py new file mode 100644 index 000000000..8efeaaa23 --- /dev/null +++ b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address.py @@ -0,0 +1,169 @@ +"""Address feature model.""" + +import textwrap +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from overture.schema.core import ( + OvertureFeature, +) +from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.primitive import ( + Geometry, + GeometryType, + GeometryTypeConstraint, +) +from overture.schema.system.string import CountryCodeAlpha2, StrippedString + + +@no_extra_fields +class AddressLevel(BaseModel): + """ + A sub-country addressing unit, such as a region, city, or neighborhood, that is less specific + than a street name and not a postal code. + + In the following address, the terms `Montréal` and `QC` are address levels: + + ``` + 3998 Rue De Bullion, Montréal, QC H2W 2E4 + ``` + + The number of address levels per address is country-dependent. + + Other addressing systems may use the terms "administrative level" or "admin level" for the + same concept. We have chosen the term "address level" to communicate the fact that in some + countries and regions, address levels do not necessarily correspond to administrative units. + """ + + value: Annotated[ + StrippedString | None, + Field( + min_length=1, + ), + ] = None + + +class Address(OvertureFeature[Literal["addresses"], Literal["address"]]): + """ + Addresses are structured labels for the geographic locations where businesses and individuals + reside. + + While address formats around the world have some general points in common, the specifics vary + extensively from place to place. The rules for dividing an address up into parts or fields vary, + as do the names of those parts or fields. + + The address schema uses a simplified approach to capture the common structure of addresses + worldwide while accommodating local variance. The schema is heavily based on the OpenAddresses + (www.openaddresses.io) project. + + For sub-country administrative levels (and non-administrative levels such as neighborhoods), the + schema provides the `address_levels` field. This is where the names of cities and towns, + provinces, state, and regions, and similar addressing units are found. + """ + + model_config = ConfigDict(title="address") + + # Core + geometry: Annotated[ + Geometry, + GeometryTypeConstraint(GeometryType.POINT), + Field(description="Position of the address. Addresses are point geometries."), + ] + + # Optional + + address_levels: Annotated[ + list[AddressLevel] | None, + Field( + min_length=1, + max_length=5, + description=textwrap.dedent(""" + Names of the sub-country addressing areas the address belongs to, including the city + or locality, in descending order of generality. + + The list is sorted so that the highest, or most general, level comes first (*e.g.*, + region) and the lowest, or most particular level, comes last (*e.g.*, city or town). + + The number of items in this list and their meaning is country-dependent. For + example, in the United States, we expect two items: the state, and the locality or + municipality within the state. Other countries might have as few as one, or even + three or more. + + When a specific level that is required for a country is not known. most likely + because the data provider has not supplied it and we have not derived it from + another source, the list item corresponding to that level must be present, but its + `value` field should be omitted. + """).strip(), + ), + ] = None + country: CountryCodeAlpha2 = Field( + description="The country the address belongs to, as an ISO 3166-1 alpha-2 country code." + ) + number: Annotated[ + StrippedString | None, + Field( + min_length=1, + description=textwrap.dedent(""" + The house number. + + This field does not necessarily contain an integer or even a number. Values such as + "74B", "189 1/2", and "208.5", where the non-integer or non-number part is part of + the house number, not a unit number, are in common use. + """).strip(), + ), + ] = None + postal_city: Annotated[ + StrippedString | None, + Field( + min_length=1, + description=textwrap.dedent(""" + The postal authority designated city name, if applicable. + + In some countries or regions, a mailing address may need to specify a different city + name than the city that actually contains the address coordinates. This optional + field can be used to specify the alternate city name to use. + + For example: + + - The postal city for the US address *716 East County Road, Winchester, Indiana* + is Ridgeville. + - The postal city for the Slovenian address *Tomaj 71, 6221 Tomaj, Slovenia* is + Dutovlje. + """).strip(), + ), + ] = None + postcode: Annotated[ + StrippedString | None, + Field( + min_length=1, + description="The postal code.", + ), + ] = None + street: Annotated[ + StrippedString | None, + Field( + min_length=1, + description=textwrap.dedent(""" + The street name. + + The street name can include a type (*e.g.*, "Street" or "St", "Boulevard" or "Blvd", + *etc.*) and a directional (*e.g.*, "NW" or "Northwest", "S" or "Sud"). Both type and + directional, if present, may be either a prefix or a suffix to the primary name. + They may either be fully spelled-out or abbreviated. + """).strip(), + ), + ] = None + unit: Annotated[ + StrippedString | None, + Field( + min_length=1, + description=textwrap.dedent(""" + The secondary address unit designator. + + In the case where the primary street address is divided into secondary units, which + may be apartments, floors, or even buildings if the primary street address is a + campus, this field names the specific secondary unit being addressed. + """).strip(), + ), + ] = None diff --git a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/__init__.py b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/__init__.py deleted file mode 100644 index 3fb6cb9e6..000000000 --- a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .models import Address - -__all__ = ["Address"] diff --git a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py deleted file mode 100644 index d10c1e058..000000000 --- a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Address feature models for Overture Maps addresses theme.""" - -from typing import Annotated, Literal - -from pydantic import BaseModel, ConfigDict, Field - -from overture.schema.core import ( - OvertureFeature, -) -from overture.schema.system.model_constraint import no_extra_fields -from overture.schema.system.primitive import ( - Geometry, - GeometryType, - GeometryTypeConstraint, -) -from overture.schema.system.string import CountryCodeAlpha2, StrippedString - - -@no_extra_fields -class AddressLevel(BaseModel): - """An address "admin level". - - We want to avoid the phrase "admin level" and have chosen "address level". These - represent states, regions, districts, cities, neighborhoods, etc. The address schema - defines several numbered levels with per-country rules indicating which parts of a - country's address goes to which numbered level. - """ - - value: Annotated[ - StrippedString | None, - Field( - min_length=1, - ), - ] = None - - -class Address(OvertureFeature[Literal["addresses"], Literal["address"]]): - """Addresses are geographic points used for locating businesses and individuals. The - rules, fields, and fieldnames of an address can vary extensively between locations. - We use a simplified schema to capture worldwide address points. This initial schema - is largely based on the OpenAddresses (www.openaddresses.io) project. - - The address schema allows up to 5 "admin levels". Rather than have field names that - apply across all countries, we provide an array called "address_levels" containing - the necessary administrative levels for an address. - """ - - model_config = ConfigDict(title="address") - - # Core - geometry: Annotated[ - Geometry, - GeometryTypeConstraint(GeometryType.POINT), - Field(description="Geometry (Point)"), - ] - - # Optional - - address_levels: Annotated[ - list[AddressLevel] | None, - Field( - min_length=1, - max_length=5, - description="""The administrative levels present in an address. The number of values in this list and their meaning is country-dependent. For example, in the United States we expect two values: the state and the municipality. In other countries there might be only one. Other countries could have three or more. The array is ordered with the highest levels first. - - Note: when a level is not known - most likely because the data provider has not supplied it and we have not derived it from another source, the array element container must be present, but the "value" field should be omitted""", - ), - ] = None - country: CountryCodeAlpha2 | None = None - number: Annotated[ - StrippedString | None, - Field( - min_length=1, - description="""The house number for this address. This field may not strictly be a number. Values such as "74B", "189 1/2", "208.5" are common as the number part of an address and they are not part of the "unit" of this address.""", - ), - ] = None - postal_city: Annotated[ - StrippedString | None, - Field( - min_length=1, - description="""In some countries or regions, a mailing address may need to specify a different city name than the city that actually contains the address coordinates. This optional field can be used to specify the alternate city name to use. - - Example from US National Address Database: - 716 East County Road, Winchester, Indiana has "Ridgeville" as its postal city - - Another example in Slovenia: - Tomaj 71, 6221 Dutovlje, Slovenia""", - ), - ] = None - postcode: Annotated[ - StrippedString | None, - Field( - min_length=1, - description="The postcode for the address", - ), - ] = None - street: Annotated[ - StrippedString | None, - Field( - min_length=1, - description="""The street name associated with this address. The street name can include the street "type" or street suffix, e.g., Main Street. Ideally this is fully spelled out and not abbreviated but we acknowledge that many address datasets abbreviate the street name so it is acceptable.""", - ), - ] = None - unit: Annotated[ - StrippedString | None, - Field( - min_length=1, - description="The suite/unit/apartment/floor number", - ), - ] = None diff --git a/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json b/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json index 880825c0a..758bbc485 100644 --- a/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json +++ b/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json @@ -2,7 +2,7 @@ "$defs": { "AddressLevel": { "additionalProperties": false, - "description": "An address \"admin level\".\n\nWe want to avoid the phrase \"admin level\" and have chosen \"address level\". These\nrepresent states, regions, districts, cities, neighborhoods, etc. The address schema\ndefines several numbered levels with per-country rules indicating which parts of a\ncountry's address goes to which numbered level.", + "description": "A sub-country addressing unit, such as a region, city, or neighborhood, that is less specific\nthan a street name and not a postal code.\n\nIn the following address, the terms `Montr\u00e9al` and `QC` are address levels:\n\n```\n3998 Rue De Bullion, Montr\u00e9al, QC H2W 2E4\n```\n\nThe number of address levels per address is country-dependent.\n\nOther addressing systems may use the terms \"administrative level\" or \"admin level\" for the\nsame concept. We have chosen the term \"address level\" to communicate the fact that in some\ncountries and regions, address levels do not necessarily correspond to administrative units.", "properties": { "value": { "description": "String with no leading/trailing whitespace", @@ -74,7 +74,7 @@ } }, "additionalProperties": false, - "description": "Addresses are geographic points used for locating businesses and individuals. The\nrules, fields, and fieldnames of an address can vary extensively between locations.\nWe use a simplified schema to capture worldwide address points. This initial schema\nis largely based on the OpenAddresses (www.openaddresses.io) project.\n\nThe address schema allows up to 5 \"admin levels\". Rather than have field names that\napply across all countries, we provide an array called \"address_levels\" containing\nthe necessary administrative levels for an address.", + "description": "Addresses are structured labels for the geographic locations where businesses and individuals\nreside.\n\nWhile address formats around the world have some general points in common, the specifics vary\nextensively from place to place. The rules for dividing an address up into parts or fields vary,\nas do the names of those parts or fields.\n\nThe address schema uses a simplified approach to capture the common structure of addresses\nworldwide while accommodating local variance. The schema is heavily based on the OpenAddresses\n(www.openaddresses.io) project.\n\nFor sub-country administrative levels (and non-administrative levels such as neighborhoods), the\nschema provides the `address_levels` field. This is where the names of cities and towns,\nprovinces, state, and regions, and similar addressing units are found.", "properties": { "bbox": { "description": "An optional bounding box for the feature", @@ -87,7 +87,7 @@ "type": "array" }, "geometry": { - "description": "Geometry (Point)", + "description": "Position of the address. Addresses are point geometries.", "properties": { "bbox": { "items": { @@ -139,7 +139,7 @@ }, "properties": { "address_levels": { - "description": "The administrative levels present in an address. The number of values in this list and their meaning is country-dependent. For example, in the United States we expect two values: the state and the municipality. In other countries there might be only one. Other countries could have three or more. The array is ordered with the highest levels first.\n\n Note: when a level is not known - most likely because the data provider has not supplied it and we have not derived it from another source, the array element container must be present, but the \"value\" field should be omitted", + "description": "Names of the sub-country addressing areas the address belongs to, including the city\nor locality, in descending order of generality.\n\nThe list is sorted so that the highest, or most general, level comes first (*e.g.*,\nregion) and the lowest, or most particular level, comes last (*e.g.*, city or town).\n\nThe number of items in this list and their meaning is country-dependent. For\nexample, in the United States, we expect two items: the state, and the locality or\nmunicipality within the state. Other countries might have as few as one, or even\nthree or more.\n\nWhen a specific level that is required for a country is not known. most likely\nbecause the data provider has not supplied it and we have not derived it from\nanother source, the list item corresponding to that level must be present, but its\n`value` field should be omitted.", "items": { "$ref": "#/$defs/AddressLevel" }, @@ -149,7 +149,7 @@ "type": "array" }, "country": { - "description": "ISO 3166-1 alpha-2 country code", + "description": "The country the address belongs to, as an ISO 3166-1 alpha-2 country code.", "maxLength": 2, "minLength": 2, "pattern": "^[A-Z]{2}$", @@ -157,21 +157,21 @@ "type": "string" }, "number": { - "description": "The house number for this address. This field may not strictly be a number. Values such as \"74B\", \"189 1/2\", \"208.5\" are common as the number part of an address and they are not part of the \"unit\" of this address.", + "description": "The house number.\n\nThis field does not necessarily contain an integer or even a number. Values such as\n\"74B\", \"189 1/2\", and \"208.5\", where the non-integer or non-number part is part of\nthe house number, not a unit number, are in common use.", "minLength": 1, "pattern": "^(\\S.*)?\\S$", "title": "Number", "type": "string" }, "postal_city": { - "description": "In some countries or regions, a mailing address may need to specify a different city name than the city that actually contains the address coordinates. This optional field can be used to specify the alternate city name to use.\n\n Example from US National Address Database:\n 716 East County Road, Winchester, Indiana has \"Ridgeville\" as its postal city\n\n Another example in Slovenia:\n Tomaj 71, 6221 Dutovlje, Slovenia", + "description": "The postal authority designated city name, if applicable.\n\nIn some countries or regions, a mailing address may need to specify a different city\nname than the city that actually contains the address coordinates. This optional\nfield can be used to specify the alternate city name to use.\n\nFor example:\n\n- The postal city for the US address *716 East County Road, Winchester, Indiana*\n is Ridgeville.\n- The postal city for the Slovenian address *Tomaj 71, 6221 Tomaj, Slovenia* is\n Dutovlje.", "minLength": 1, "pattern": "^(\\S.*)?\\S$", "title": "Postal City", "type": "string" }, "postcode": { - "description": "The postcode for the address", + "description": "The postal code.", "minLength": 1, "pattern": "^(\\S.*)?\\S$", "title": "Postcode", @@ -188,7 +188,7 @@ "uniqueItems": true }, "street": { - "description": "The street name associated with this address. The street name can include the street \"type\" or street suffix, e.g., Main Street. Ideally this is fully spelled out and not abbreviated but we acknowledge that many address datasets abbreviate the street name so it is acceptable.", + "description": "The street name.\n\nThe street name can include a type (*e.g.*, \"Street\" or \"St\", \"Boulevard\" or \"Blvd\",\n*etc.*) and a directional (*e.g.*, \"NW\" or \"Northwest\", \"S\" or \"Sud\"). Both type and\ndirectional, if present, may be either a prefix or a suffix to the primary name.\nThey may either be fully spelled-out or abbreviated.", "minLength": 1, "pattern": "^(\\S.*)?\\S$", "title": "Street", @@ -205,7 +205,7 @@ "type": "string" }, "unit": { - "description": "The suite/unit/apartment/floor number", + "description": "The secondary address unit designator.\n\nIn the case where the primary street address is divided into secondary units, which\nmay be apartments, floors, or even buildings if the primary street address is a\ncampus, this field names the specific secondary unit being addressed.", "minLength": 1, "pattern": "^(\\S.*)?\\S$", "title": "Unit", @@ -222,7 +222,8 @@ "required": [ "theme", "type", - "version" + "version", + "country" ], "type": "object" }, diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py index 3b6409c5d..5e858200a 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py @@ -13,7 +13,8 @@ from overture.schema.core import ( OvertureFeature, ) -from overture.schema.core.models import Named, Stacked +from overture.schema.core.models import Stacked +from overture.schema.core.names import Named from overture.schema.system.primitive import ( Geometry, GeometryType, diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py index 2dc3c9b65..c67a2c72c 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py @@ -10,7 +10,8 @@ from overture.schema.core import ( OvertureFeature, ) -from overture.schema.core.models import Named, Stacked +from overture.schema.core.models import Stacked +from overture.schema.core.names import Named from overture.schema.system.primitive import ( Geometry, GeometryType, diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py index ac8838224..0d3f5072d 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py @@ -10,7 +10,8 @@ from overture.schema.core import ( OvertureFeature, ) -from overture.schema.core.models import Named, Stacked +from overture.schema.core.models import Stacked +from overture.schema.core.names import Named from overture.schema.system.primitive import ( Geometry, GeometryType, diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py index 08c16eab5..f63fe6862 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py @@ -9,7 +9,8 @@ from overture.schema.core import ( OvertureFeature, ) -from overture.schema.core.models import Named, Stacked +from overture.schema.core.models import Stacked +from overture.schema.core.names import Named from overture.schema.system.primitive import ( Geometry, GeometryType, diff --git a/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json b/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json index 719fbfc62..3674ae825 100644 --- a/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json @@ -200,7 +200,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -222,7 +222,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -333,7 +334,7 @@ "type": "object" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-base-theme/tests/land_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_baseline_schema.json index 78d54bbe9..55022ea51 100644 --- a/packages/overture-schema-base-theme/tests/land_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_baseline_schema.json @@ -74,7 +74,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -96,7 +96,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -207,7 +208,7 @@ "type": "object" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json index 0a8ab930a..a4dd509a1 100644 --- a/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json @@ -152,7 +152,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -174,7 +174,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -285,7 +286,7 @@ "type": "object" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-base-theme/tests/water_baseline_schema.json b/packages/overture-schema-base-theme/tests/water_baseline_schema.json index c8b04cb46..60be51909 100644 --- a/packages/overture-schema-base-theme/tests/water_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/water_baseline_schema.json @@ -5,7 +5,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -27,7 +27,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -138,7 +139,7 @@ "type": "object" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py index 34f2a9705..930183418 100644 --- a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py +++ b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py @@ -5,7 +5,8 @@ from pydantic import ConfigDict, Field from overture.schema.core import OvertureFeature -from overture.schema.core.models import Named, Stacked +from overture.schema.core.models import Stacked +from overture.schema.core.names import Named from overture.schema.system.primitive import ( Geometry, GeometryType, diff --git a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py index 3ca522cf5..bb42d466f 100644 --- a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py +++ b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py @@ -5,7 +5,8 @@ from pydantic import Field from overture.schema.core import OvertureFeature -from overture.schema.core.models import Named, Stacked +from overture.schema.core.models import Stacked +from overture.schema.core.names import Named from overture.schema.system.primitive import ( Geometry, GeometryType, diff --git a/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json b/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json index 61315161a..c24f25ba3 100644 --- a/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json +++ b/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json @@ -117,7 +117,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -139,7 +139,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -301,7 +302,7 @@ "type": "string" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json b/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json index 6bc26de5c..efb42c862 100644 --- a/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json +++ b/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json @@ -23,7 +23,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -45,7 +45,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -207,7 +208,7 @@ "type": "string" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-core/README.md b/packages/overture-schema-core/README.md index 461f1916a..f4938f871 100644 --- a/packages/overture-schema-core/README.md +++ b/packages/overture-schema-core/README.md @@ -109,76 +109,6 @@ process_floor_count(area) # mypy error: Expected UInt8, got UInt32 process_area(floors) # mypy error: Expected UInt32, got UInt8 ``` -## Scoping System - -The scoping system enables precise conditional application of rules based on geometric, -temporal, directional, and subjective criteria. This is essential for transportation -rules like speed limits, access restrictions, and other regulations that apply under -specific conditions. - -### Architecture - -The scoping system follows a **mix-in architecture** with two tiers: - -1. **Geometric Scoping**: Where along a linear feature (using linear referencing) -2. **Conditional Scoping**: When and how rules apply (temporal, directional, subjective) - -### Core Scopes - -#### Individual Scopes - -Each scope class handles a specific dimension of conditional logic: - -- **`GeometricRangeScope`**: Linear referencing with `between: [start, end]` -- **`TemporalScope`**: Time-based conditions using OSM opening hours format -- **`HeadingScope`**: Directional application (`forward`/`backward`) -- **`TravelModeScope`**: Travel mode filtering (car, bike, foot, etc.) -- **`PurposeOfUseScope`**: Usage purpose filtering (delivery, destination, etc.) -- **`RecognizedStatusScope`**: Recognition status (private, employee, etc.) -- **`VehicleScope`**: Vehicle attribute constraints (weight, height, etc.) - -#### Composite Scoping - -**`ScopingConditions`**: Inherits from all individual scopes to provide comprehensive scoping capabilities in a single class. - -### Usage Patterns - -#### Basic Geometric Scoping - -```python -from overture.schema.core.common import GeometricRangeScope - -class WidthRule(GeometricRangeScope): - width: Dimension -``` - -#### Complex Conditional Scoping - -```python -from overture.schema.core.common import GeometricRangeScope, ScopingConditions - -class SpeedLimitWhenClause( - TemporalScope, - HeadingScope, - PurposeOfUseScope, - RecognizedStatusScope, - TravelModeScope, - VehicleScope -): - pass - -class SpeedLimitRule(GeometricRangeScope): - max_speed: Speed - when: Optional[SpeedLimitWhenClause] = None -``` - -#### Full Scoping Integration - -```python -class AccessRestrictionRule(GeometricRangeScope): - access_type: AccessType - when: Optional[AccessRestrictionWhenClause] = None -``` ### Examples diff --git a/packages/overture-schema-core/src/overture/schema/core/__init__.py b/packages/overture-schema-core/src/overture/schema/core/__init__.py index 2c42f6534..69096a294 100644 --- a/packages/overture-schema-core/src/overture/schema/core/__init__.py +++ b/packages/overture-schema-core/src/overture/schema/core/__init__.py @@ -1,5 +1,14 @@ +from . import scoping from .json_schema import json_schema from .models import OvertureFeature from .parser import parse_feature +from .scoping import Scope, scoped -__all__ = ["OvertureFeature", "json_schema", "parse_feature"] +__all__ = [ + "json_schema", + "OvertureFeature", + "parse_feature", + "Scope", + "scoped", + "scoping", +] diff --git a/packages/overture-schema-core/src/overture/schema/core/enums.py b/packages/overture-schema-core/src/overture/schema/core/enums.py index 19aed8d9e..b70f4b67f 100644 --- a/packages/overture-schema-core/src/overture/schema/core/enums.py +++ b/packages/overture-schema-core/src/overture/schema/core/enums.py @@ -1,22 +1,6 @@ from enum import Enum -class NameVariant(str, Enum): - COMMON = "common" - OFFICIAL = "official" - ALTERNATE = "alternate" - SHORT = "short" - - -class Side(str, Enum): - """Represents the side on which something appears relative to a facing or heading - direction, e.g. the side of a road relative to the road orientation, or relative to - the direction of travel of a person or vehicle.""" - - LEFT = "left" - RIGHT = "right" - - class PerspectiveMode(str, Enum): """Perspective mode for disputed names.""" diff --git a/packages/overture-schema-core/src/overture/schema/core/models.py b/packages/overture-schema-core/src/overture/schema/core/models.py index d1d679a09..ee999b3a2 100644 --- a/packages/overture-schema-core/src/overture/schema/core/models.py +++ b/packages/overture-schema-core/src/overture/schema/core/models.py @@ -22,19 +22,17 @@ from overture.schema.system.string import ( CountryCodeAlpha2, JsonPointer, - LanguageTag, - RegionCode, StrippedString, ) -from .enums import NameVariant, PerspectiveMode, Side +from .enums import PerspectiveMode +from .scoping.lr import LinearlyReferencedRange +from .scoping.side import Side from .types import ( - CommonNames, ConfidenceScore, FeatureUpdateTime, FeatureVersion, Level, - LinearlyReferencedRange, MaxZoom, MinZoom, Prominence, @@ -203,56 +201,6 @@ class Perspectives(BaseModel): ] -@no_extra_fields -class NameRule(GeometricRangeScope, SideScope): - """Name rule with variant and language specification.""" - - # Required - - value: Annotated[StrippedString, Field(min_length=1)] - variant: NameVariant - - # Optional - - language: LanguageTag | None = None - perspectives: ( - Annotated[ - Perspectives, - Field( - description="Political perspectives from which a named feature is viewed." - ), - ] - | None - ) = None - - -@no_extra_fields -class Names(BaseModel): - """Multilingual names container.""" - - # Required - - primary: Annotated[ - StrippedString, Field(min_length=1, description="The most commonly used name.") - ] - - # Optional - - common: CommonNames | None = None - rules: Annotated[ - list[NameRule] | None, - Field( - description="Rules for names that cannot be specified in the simple common names property. These rules can cover other name variants such as official, alternate, and short; and they can optionally include geometric scoping (linear referencing) and side-of-road scoping for complex cases.", - ), - ] = None - - -class Named(BaseModel): - """Properties defining the names of a feature.""" - - names: Names | None = None - - class Stacked(BaseModel): """Properties defining feature Z-order, i.e., stacking order.""" @@ -273,27 +221,3 @@ class CartographicHints(BaseModel): class CartographicallyHinted(BaseModel): cartography: Annotated[CartographicHints | None, Field(title="cartography")] = None - - -# TODO - vic - move this into Places -@no_extra_fields -class Address(BaseModel): - # Optional - - freeform: Annotated[ - str | None, - Field( - description="Free-form address that contains street name, house number and other address info", - ), - ] = None - locality: Annotated[ - str | None, - Field( - description="Name of the city or neighborhood where the address is located", - ), - ] = None - postcode: Annotated[ - str | None, Field(description="Postal code where the address is located") - ] = None - region: RegionCode | None = None - country: CountryCodeAlpha2 | None = None diff --git a/packages/overture-schema-core/src/overture/schema/core/names.py b/packages/overture-schema-core/src/overture/schema/core/names.py new file mode 100644 index 000000000..7d454319a --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/names.py @@ -0,0 +1,88 @@ +from enum import Enum +from typing import Annotated, NewType + +from pydantic import BaseModel, Field + +from overture.schema.core.models import Perspectives +from overture.schema.core.scoping import Scope, scoped +from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.string import ( + LanguageTag, + StrippedString, +) + +CommonNames = NewType( + "CommonNames", + Annotated[ + dict[ + Annotated[ + LanguageTag, + Field( + description="""Each entry consists of a key that is an IETF-BCP47 language tag; and a value that reflects the common name in the language represented by the key's language tag. + +The validating regular expression for this property follows the pattern described in https://www.rfc-editor.org/rfc/bcp/bcp47.txt with the exception that private use tags are not supported.""" + ), + ], + StrippedString, + ], + Field(json_schema_extra={"additionalProperties": False}), + ], +) + + +class NameVariant(str, Enum): + COMMON = "common" + OFFICIAL = "official" + ALTERNATE = "alternate" + SHORT = "short" + + +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE, Scope.SIDE) +class NameRule(BaseModel): + """Name rule with variant and language specification.""" + + # Required + + value: Annotated[StrippedString, Field(min_length=1)] + variant: NameVariant + + # Optional + + language: LanguageTag | None = None + perspectives: ( + Annotated[ + Perspectives, + Field( + description="Political perspectives from which a named feature is viewed." + ), + ] + | None + ) = None + + +@no_extra_fields +class Names(BaseModel): + """Multilingual names container.""" + + # Required + + primary: Annotated[ + StrippedString, Field(min_length=1, description="The most commonly used name.") + ] + + # Optional + + common: CommonNames | None = None + rules: Annotated[ + list[NameRule] | None, + Field( + description="Rules for names that cannot be specified in the simple common names property. These rules can cover other name variants such as official, alternate, and short; and they can optionally include geometric scoping (linear referencing) and side-of-road scoping for complex cases.", + ), + ] = None + + +class Named(BaseModel): + """Properties defining the names of a feature.""" + + names: Names | None = None diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping.py b/packages/overture-schema-core/src/overture/schema/core/scoping.py deleted file mode 100644 index cb13ff566..000000000 --- a/packages/overture-schema-core/src/overture/schema/core/scoping.py +++ /dev/null @@ -1,208 +0,0 @@ -from collections.abc import ( - Callable, - Collection, -) -from enum import Enum -from typing import ( - Annotated, - Any, - Union, - get_args, - get_origin, -) - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - create_model, -) - -from overture.schema.system.model_constraint import RequireAnyOfConstraint - - -class Scope(Enum): - GEOMETRIC_POINT = (1,) - GEOMETRIC_RANGE = (2,) - HEADING = (3,) - TEMPORAL = (4,) - TRAVEL_MODE = (5,) - PURPOSE_OF_USE = (6,) - RECOGNIZED_STATUS = (7,) - SIDE = (8,) - VEHICLE = (9,) - - @property - def _field_name(self) -> str: - match self: - case Scope.GEOMETRIC_POINT: - return "at" - case Scope.GEOMETRIC_RANGE: - return "between" - case Scope.HEADING: - return "heading" - case Scope.TEMPORAL: - return "during" - case Scope.TRAVEL_MODE: - return "mode" - case Scope.PURPOSE_OF_USE: - return "using" - case Scope.RECOGNIZED_STATUS: - return "recognized" - case Scope.SIDE: - return "side" - case Scope.VEHICLE: - return "vehicle" - case _: - raise RuntimeError(f"unexpected scope: {self}") - - -def scoped( - allowed: Scope | Collection[Scope], - required: Scope | Collection[Scope] | None = None, -) -> Callable: - def collect_scopes( - context: str, scopes: Scope | Collection[Scope] - ) -> frozenset[Scope]: - if isinstance(scopes, Scope): - return frozenset({scopes}) - elif not isinstance(scopes, Collection): - raise TypeError( - f"for `@scoped`, {context} must be a `Scope` or a collection of `Scope` values but given value of type {type(scopes).__name__} is neither" - ) - elif not all(isinstance(s, Scope) for s in scopes): - raise TypeError( - f"for `@scoped`, all members of the {context} collection must be a `Scope`, but at least one value is not" - ) - elif not scopes: - raise ValueError( - "for `@scoped`, at least one scope must be allowed, but `allowed` is empty" - ) - else: - return frozenset(scopes) - - allowed = collect_scopes("allowed", allowed) - - if required is None: - required = frozenset() - else: - required = collect_scopes("required", required) - not_in_allowed = [s for s in required if s not in allowed] - if not_in_allowed: - raise ValueError( - f"for `@scoped`, all required values must be allowed; but {not_in_allowed} are required but not allowed" - ) - - from typing import cast - - new_fields = cast(dict[str, Any], _make_scoped_fields(allowed, required)) - - def decorator(model_class: type[BaseModel]) -> type[BaseModel]: - if not isinstance(model_class, type): - raise TypeError("`@scoped` can only be applied to classes") - if not issubclass(model_class, BaseModel): - raise TypeError( - f"`@scoped` target class must inherit from `{BaseModel.__module__}.{BaseModel.__name__}`" - ) - conflict_fields = sorted( - [f for f in model_class.model_fields.keys() if f in new_fields] - ) - if conflict_fields: - raise TypeError( - f"can't apply `@scoped` to model {model_class.__name__}: the following model fields conflict with fields `@scoped` needs to create: {', '.join(conflict_fields)})" - ) - return create_model( - model_class.__name__, - __doc__=model_class.__doc__, - __base__=model_class, - __module__=model_class.__module__, - **new_fields, - ) - - return decorator - - -# This is a value type and can be exported for reuse. -GeometricPoint = Annotated[float, Field(ge=0, le=1)] - - -# This is a value type and can be exported for reuse. -GeometricRange = Annotated[list[GeometricPoint], Field(min_length=2, max_length=2)] - - -# This is a value type and can be exported for reuse. -class Heading(str, Enum): - FORWARD = "forward" - BACKWARD = "backward" - - -def _make_scoped_fields( - allowed: frozenset[Scope], required: frozenset[Scope] -) -> dict[str, tuple[type[Any], Any]]: - scoped_fields: dict[str, tuple[type[Any], Any]] = {} - - if Scope.GEOMETRIC_POINT in allowed: - _put_scoped_field( - Scope.GEOMETRIC_POINT, required, "at", GeometricPoint, scoped_fields - ) - - if Scope.GEOMETRIC_RANGE in allowed: - _put_scoped_field( - Scope.GEOMETRIC_RANGE, required, "between", GeometricRange, scoped_fields - ) - - when_fields: dict[str, tuple[type[Any], Any]] = {} - - if Scope.HEADING in allowed: - _put_scoped_field(Scope.HEADING, required, "heading", Heading, when_fields) - - # TODO: Put other when-wrapped scopes here. - - if when_fields: - has_required = any(_is_required_type(pair[0]) for pair in when_fields.values()) - if has_required: - scoped_fields["when"] = (_make_when(when_fields), ...) # type: ignore - elif len(when_fields) == 1: - ((field_name, field_type),) = when_fields.items() - field_type = (_unpack_optional_inner_type(field_type[0]), ...) - scoped_fields["when"] = _make_when({field_name: field_type}) # type: ignore - else: - when = _make_when(when_fields) - when = RequireAnyOfConstraint(*when_fields.keys()).decorate(when) - scoped_fields["when"] = (when.__class__ | None, None) # type: ignore - - return scoped_fields - - -def _put_scoped_field( - scope: Scope, - required: frozenset[Scope], - field_name: str, - field_type: type[Any], - into: dict[str, tuple[type[Any], Any]], -) -> None: - if scope in required: - into[field_name] = (field_type, ...) - else: - into[field_name] = (field_type | None, None) # type: ignore - - -def _is_optional_type(t: type[Any]) -> bool: - return get_origin(t) is Union and type(None) in get_args(t) - - -def _is_required_type(t: type[Any]) -> bool: - return not _is_optional_type(t) - - -def _unpack_optional_inner_type(t: type[Any]) -> type[Any]: - assert _is_optional_type(t) - non_none_types = [ - arg for arg in get_args(t) if isinstance(arg, type) and arg is not type(None) - ] - assert len(non_none_types) == 1 - return non_none_types[0] - - -def _make_when(when_fields: dict[str, tuple[Any, Any]]) -> type[BaseModel]: - return create_model("When", __config__=ConfigDict(extra="forbid"), **when_fields) # type: ignore diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/__init__.py b/packages/overture-schema-core/src/overture/schema/core/scoping/__init__.py new file mode 100644 index 000000000..c4a4fbbae --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/__init__.py @@ -0,0 +1,394 @@ +""" +Decorates Pydantic models with scoping fields. + +What is scoping? +================ +In the Overture schema, a scoped value is one that applies only when certain conditions are true. + +A simple example is geometric range scoping, better known as linear referencing. In geometric +scoping, the scoped values apply only to designated sub-segments along a linear geometry. + +Suppose you are modeling subterranean infrastructure with a LineString feature representing tunnels, +and suppose you want to record depth observations for each tunnel along different sub-segments. +Perhaps a tunnel is 10 meters deep for the first 100 meters, then rises a bit to only 7 meters deep +for the next 100 meters. These depth observations would be modeled in the Overture schema using +geometric range scoping (linear referencing): you would give your tunnel model an array of depth +obervations, and each observation would contain a linearly-referenced depth value. + +>>> from typing import Annotated, Literal +>>> from pydantic import BaseModel +>>> from overture.schema.core.models import OvertureFeature +>>> from overture.schema.system.primitive import ( +... float32, +... Geometry, +... GeometryType, +... GeometryTypeConstraint +... ) +... +>>> @scoped(Scope.GEOMETRIC_RANGE) +... class Depth(BaseModel): +... value: float32 +... +>>> class Tunnel(OvertureFeature[Literal['underground'], Literal['tunnel']]): +... geometry: Annotated[Geometry, GeometryTypeConstraint(GeometryType.LINE_STRING)] +... depth: list[Depth] | None = None +... +>>> tunnel = Tunnel( +... id='tunnel_001', +... theme='underground', +... type='tunnel', +... version=1, +... geometry=Geometry.from_wkt('LINESTRING (0 0, 1 1)'), +... depth=[Depth(between=[0, 0.15], value=10), Depth(between=[0.15, 0.30], value=7)] +... ) + + +Why use scoping? +================ +Scoping provides a repeatable, consistent, framework for expressing the idea that a specific value +only applies in specific circumstances, such as: at specific times, in specific places, to specific +individuals or vehicle, *etc.* + +By using scoping, you ensure that your schema is consistent with the Overture schema, will be +widely understood by humans, and will be able to be consumed by automated tools that understand +Overture structure and conventions. + + +Types of scoping +================ +The authoritative list of available scopes is enumerated in `overture.schema.core.scoping.Scope`. + +The following scopes are available: + +Geometric position scope (point events): +---------------------------------------- +Geometric position scoping allows a value to be tied to a specific point along a linear path using +linear referencing. When a model is decorated with geometric position scoping, an `at` field is +automatically added to the model. This `at` field is used to specify the position along the linear +path. As with geometric range scoping, `at` values are specified as percentage offsets from the +start of the path, where `0.0` represents the start of the path, `0.5` represents the point halfway +along the path, `1.0` represents the end of the path, and so on. + +>>> from typing import Annotated, Literal +>>> from pydantic import BaseModel +>>> from overture.schema.core.models import OvertureFeature +>>> from overture.schema.system.primitive import ( +... Geometry, +... GeometryType, +... GeometryTypeConstraint, +... uint32, +... ) +... +>>> @scoped(required=Scope.GEOMETRIC_POSITION) +... class Transformer(BaseModel): +... power_capacity: uint32 +... +>>> class PowerLine(OvertureFeature[Literal['power'], Literal['line']]): +... geometry: Annotated[Geometry, GeometryTypeConstraint(GeometryType.LINE_STRING)] +... transformers: list[Transformer] | None = None +... +>>> power_line = PowerLine( +... id='power_line_001', +... theme='power', +... type='line', +... version=1, +... geometry=Geometry.from_wkt('LINESTRING (0 0, 1 1)'), +... transformers=[Transformer(at=0.73, power_capacity=167)] +... ) + + +Geometric range scope (linear events): +-------------------------------------- +Geometric range scoping allows a value to be tied to a sub-segment of a linear path using linear +referencing. When a model is decorated with geometric range scoping, a `between` field is +automatically added to the model. This `between` field is used to specify the start and end +positions of the range along the linear path. The `between` field is a pair (a list or array of +length exactly two) and as with the geometric point scoping `at` field, the `between` pair values +indicate percentage offsets from the start of the path, where `0.0` represents the start of the +path and `1.0` represents the end. + +See the section *What is scoping?*, above, for an example of geometric range scoping. + + +Heading scope (forward and backward): +------------------------------------- +Heading scoping allows a value to be tied to one of the two possible headings or facings along a +linear path: forward (toward the end of the path) or backward (toward the start of the path). When +a model is decorated with heading scoping, a `when.heading` field is automatically added to the +model. + +>>> from typing import Annotated, Literal +>>> from pydantic import BaseModel, Field +>>> from overture.schema.core.models import OvertureFeature +>>> from overture.schema.system.primitive import ( +... Geometry, +... GeometryType, +... GeometryTypeConstraint, +... uint32, +... ) +>>> from overture.schema.system.string import StrippedString +... +>>> @scoped(required=Scope.HEADING) +... class Designation(BaseModel): +... value: StrippedString +... +>>> class Runway(OvertureFeature[Literal['airport'], Literal['runway']]): +... geometry: Annotated[Geometry, GeometryTypeConstraint(GeometryType.LINE_STRING)] +... designations: list[Designation] = Field(min_length=1, max_length=2) +... +>>> jfk_04l_22r = Runway( +... id='jfk_04l_22r', +... theme='airport', +... type='runway', +... version=1, +... geometry=Geometry.from_wkt( +... 'LINESTRING (-73.785585 40.622035, -73.763323 40.650515)' +... ), +... designations=[ +... Designation(value='04L', when=Designation.When(heading=Heading.FORWARD)), +... Designation(value='22R', when=Designation.When(heading=Heading.BACKWARD)), +... ] +... ) + + +Temporal scoping (recurring time patterns): +------------------------------------------- +Temporal scoping allows a value to be tied to specific one-time or recurring time ranges. When a +model is decorated with temporal scoping, a `when.during` field is automatically added to the model. +The `during` field contains a time pattern formatted according to the OpenStreetMap +[opening hours specification](https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification). + +>>> from pydantic import BaseModel +>>> from overture.schema.system.string import StrippedString +>>> @scoped(Scope.TEMPORAL) +... class WiFiHotspot(BaseModel): +... '''A public WiFi hotspot''' +... ssid: StrippedString +... +>>> hotspots=[ +... WiFiHotspot(ssid="always_on"), +... WiFiHotspot(ssid="daytime_use_only", when=WiFiHotspot.When(during="06:00-22:00")) +... ] + + +Travel mode: +------------ +Travel mode scoping allows a value to be tied to one or more modes of travel, for example driving a +motor vehicle, driving a car, or walking on foot. When a model is decorated with travel mode +scoping, a `when.mode` field is automatically added to the model. The `mode` field is a list +accepting one or more unique members of the `TravelMode` enumeration. Travel mode scoping can be +used to express concepts such as "access is only allowed if you are traveling in this way", or +"turns are prohibited unless you are traveling in this way". + + +Purpose of use: +--------------- +Purpose of use scoping allows a value to be tied to one or more reasons why an actor might be using +a feature. Examples include "I am only using this road to make a delivery and then will leave", or +"I am not through traffic; I am using this road because it is my destination." + +When a model is decorated with purpose of use scoping, a `when.using` field is automatically added +to the model. The `using` field is a list accepting one or more unique members of the `PurposeOfUse` +enumeration. Purpose of use scoping can be used to express concepts such as "access is only allowed +if you are here for this reason". + + +Recognized status: +------------------ +Recognized status scoping allows a value to be tied to one or more statuses that an actor might be +recognized as having. Examples include "I am an employee of this business", "I have a permit", or +"I have a recognized disability". + +When a model is decorated with recognized status scoping, a `when.recognized` field is automatically +added to the model. The `recognized` field is a list accepting one or more unique members of the +`RecognizedStatus` enumeration. Recognized status scoping can be used to express concepts such as +"access is only allowed if you are recognized as having this status". + + +Side (left or right): +--------------------- +Side scoping allows a value to be tied to the left- or right-hand side of something. When a model +is decorated with side scoping, a `side` field is automatically added to the model. + +>>> from typing import Annotated, Literal +>>> from pydantic import BaseModel +>>> from overture.schema.core.models import OvertureFeature +>>> from overture.schema.system.primitive import ( +... Geometry, +... GeometryType, +... GeometryTypeConstraint, +... ) +>>> from overture.schema.system.string import StrippedString +... +>>> @scoped(required=(Scope.SIDE, Scope.GEOMETRIC_POSITION)) +... class BusStop(BaseModel): +... route: StrippedString +... +>>> class BusLoadingIsland(OvertureFeature[Literal['bus_terminal'], Literal['loading_island']]): +... geometry: Annotated[Geometry, GeometryTypeConstraint(GeometryType.LINE_STRING)] +... stops: list[BusStop] | None = None +... +>>> island = BusLoadingIsland( +... id='island_001', +... theme='bus_terminal', +... type='loading_island', +... version=1, +... geometry=Geometry.from_wkt('LINESTRING (0 0, 1 1)'), +... stops=[ +... BusStop(route='15', side=Side.LEFT, at=0.10), +... BusStop(route='B-Line', side=Side.RIGHT, at=0.25), +... ] +... ) + +When used on a linear feature, the side is interpreted with reference to the geometry's orientation. +Specifically, the value `Side.LEFT` is on the left of a person who is facing forward (toward the +end of the geometry), and `Side.RIGHT` is likewise on this person's right. + + +Vehicle: +-------- +Vehicle scoping allows a value to be tied to one or more properties of a vehicle, such as height, +weight, or number of axles. This enables a wide variety of use cases such as restricting the +allowed weight of trucks on bridges, limiting the allowed height of vehicles crossing under +bridges or entering garages, applying differential speed limits to different classes of vehicle, +*etc.* When a model is decorated with vehicle scoping, a `when.vehicle` field is automatically +added to the model. + +>>> from overture.schema.core.unit import LengthUnit +>>> from overture.schema.system.primitive import float32 +... +>>> @scoped(Scope.VEHICLE) +... class Fare(BaseModel): +... value: float32 +... +>>> fare_schedule: list[Fare] = [ +... Fare( +... value=10, +... when=Fare.When(vehicle=[ +... VehicleAxleCountSelector( +... dimension=VehicleDimension.AXLE_COUNT, +... comparison=VehicleRelation.LESS_THAN, +... value=3 +... ), +... VehicleLengthSelector( +... dimension=VehicleDimension.LENGTH, +... comparison=VehicleRelation.LESS_THAN_EQUAL, +... value=18, +... unit=LengthUnit.FT, +... ) +... ]), +... ), +... Fare(value=30), +... ] + + + +Mixing scopes +============== +The `@scoped` decorator can mix any desired combination of scopes onto your Pydantic model. + +For example, suppose you are modeling a value type that can apply at certain times, along a certain +sub-segment of a linear path, depending on whether one is traveling forward or backward along the +path, or any combination of these three. This can be achieved easily with: + +>>> @scoped(Scope.TEMPORAL, Scope.GEOMETRIC_RANGE, Scope.HEADING) +... class MyModel(BaseModel): +... pass + + +Optional and required scopes +============================ +When using the `@scoped` decorator, scopes are optional by default but some or all scopes may be +made required. + +The following example makes all the scopes required: + +>>> from enum import Enum +... +>>> class SignalType(str, Enum): +... STOP_SIGN = 'stop_sign' +... +>>> @scoped(required=(Scope.GEOMETRIC_POSITION, Scope.HEADING)) +... class TrafficSignal(BaseModel): +... signal_type: SignalType + +The following example mixes an optional scope (temporal) with two required scopes (geometric +position and heading). + +>>> @scoped(Scope.TEMPORAL, required=(Scope.GEOMETRIC_POSITION, Scope.HEADING)) +... class TrafficSignal(BaseModel): +... signal_type: SignalType + + +The `when` clause +================= +For historical reasons, some scope fields are added directly to the decorated model, while others +are added as children of a synthetic `when` field. + +| Scope | Field | +|----------------------------|-------------------| +| `Scope.GEOMETRIC_POSITION` | `at` | +| `Scope.GEOMETRIC_RANGE` | `between` | +| `Scope.HEADING` | `when.heading` | +| `Scope.TEMPORAL` | `when.during` | +| `Scope.TRAVEL_MODE` | `when.mode` | +| `Scope.PURPOSE_OF_USE` | `when.using` | +| `Scope.RECOGNIZED_STATUS` | `when.recognized` | +| `Scope.SIDE` | `side` | +| `Scope.VEHICLE` | `when.vehicle` | + + +If a `when` field is added to the model, the model is also decorated with a nested `When` class to +simplify instantiating values for the `when` field, for example: + +>>> from overture.schema.system.primitive import uint8 +... +>>> @scoped(Scope.HEADING) +... class MyModel(BaseModel): +... value: uint8 +... +>>> MyModel(value=10) +MyModel(value=10, when=None) +>>> MyModel(value=15, when=MyModel.When(heading=Heading.BACKWARD)) +MyModel(value=15, when=MyModel.When(heading=)) +""" + +from .heading import Heading +from .lr import LinearlyReferencedPosition, LinearlyReferencedRange +from .opening_hours import OpeningHours +from .purpose_of_use import PurposeOfUse +from .recognized_status import RecognizedStatus +from .scoped import Scope, scoped +from .side import Side +from .travel_mode import TravelMode +from .vehicle import ( + VehicleAxleCountSelector, + VehicleDimension, + VehicleHeightSelector, + VehicleLengthSelector, + VehicleRelation, + VehicleSelector, + VehicleWeightSelector, + VehicleWidthSelector, +) + +__all__ = [ + "Heading", + "LinearlyReferencedPosition", + "LinearlyReferencedRange", + "OpeningHours", + "PurposeOfUse", + "RecognizedStatus", + "Scope", + "scoped", + "Side", + "TravelMode", + "VehicleAxleCountSelector", + "VehicleDimension", + "VehicleHeightSelector", + "VehicleLengthSelector", + "VehicleRelation", + "VehicleSelector", + "VehicleWeightSelector", + "VehicleWidthSelector", +] diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/heading.py b/packages/overture-schema-core/src/overture/schema/core/scoping/heading.py new file mode 100644 index 000000000..01d3966b0 --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/heading.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class Heading(str, Enum): + """ + Travel direction along an oriented path: forward or backward. + """ + + FORWARD = "forward" + BACKWARD = "backward" diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/lr.py b/packages/overture-schema-core/src/overture/schema/core/scoping/lr.py new file mode 100644 index 000000000..6c548bdaa --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/lr.py @@ -0,0 +1,104 @@ +from typing import Annotated, Any, NewType + +from pydantic import Field, GetJsonSchemaHandler, ValidationError, ValidationInfo +from pydantic_core import InitErrorDetails, core_schema + +from overture.schema.system.field_constraint import CollectionConstraint +from overture.schema.system.primitive import float64 + +# One possible advantage to using percentages over absolute distances is being able to +# trivially validate that the position lies "on" its segment (i.e. is between zero and +# one). Of course, this level of validity doesn't mean the number isn't nonsense +LinearlyReferencedPosition = NewType( + "LinearlyReferencedPosition", + Annotated[ + float64, + Field( + description="Represents a linearly-referenced position between 0% and 100% of the distance along a path such as a road segment or a river center-line segment.", + ge=0.0, + le=1.0, + ), + ], +) + + +class LinearReferenceRangeConstraint(CollectionConstraint): + """Linear reference range constraint (0.0 to 1.0).""" + + def validate(self, value: list[float], info: ValidationInfo) -> None: + if len(value) != 2: + context = info.context or {} + loc = context.get("loc_prefix", ()) + ("value",) + raise ValidationError.from_exception_data( + title=self.__class__.__name__, + line_errors=[ + InitErrorDetails( + type="value_error", + loc=loc, + input=value, + ctx={ + "error": f"Linear reference range must have exactly 2 values, got {len(value)}" + }, + ) + ], + ) + + start, end = value + if not (0.0 <= start <= 1.0 and 0.0 <= end <= 1.0): + context = info.context or {} + loc = context.get("loc_prefix", ()) + ("value",) + raise ValidationError.from_exception_data( + title=self.__class__.__name__, + line_errors=[ + InitErrorDetails( + type="value_error", + loc=loc, + input=value, + ctx={ + "error": f"Linear reference range values must be between 0.0 and 1.0: [{start}, {end}]" + }, + ) + ], + ) + + if start >= end: + context = info.context or {} + loc = context.get("loc_prefix", ()) + ("value",) + raise ValidationError.from_exception_data( + title=self.__class__.__name__, + line_errors=[ + InitErrorDetails( + type="value_error", + loc=loc, + input=value, + ctx={ + "error": f"Linear reference range start must be less than end: [{start}, {end}]" + }, + ) + ], + ) + + def __get_pydantic_json_schema__( + self, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> dict[str, Any]: + json_schema = handler(schema) + json_schema["type"] = "array" + json_schema["minItems"] = 2 + json_schema["maxItems"] = 2 + json_schema["items"] = {"type": "number", "minimum": 0.0, "maximum": 1.0} + json_schema["description"] = ( + "Linear reference range [start, end] where 0.0 <= start < end <= 1.0" + ) + return json_schema + + +LinearlyReferencedRange = NewType( + "LinearlyReferencedRange", + Annotated[ + list[LinearlyReferencedPosition], + LinearReferenceRangeConstraint(), + Field( + description="Represents a non-empty range of positions along a path as a pair linearly-referenced positions. For example, the pair [0.25, 0.5] represents the range beginning 25% of the distance from the start of the path and ending 50% of the distance from the path", + ), + ], +) diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/opening_hours.py b/packages/overture-schema-core/src/overture/schema/core/scoping/opening_hours.py new file mode 100644 index 000000000..87d569f54 --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/opening_hours.py @@ -0,0 +1,17 @@ +# Validating the opening hours value is going to have to happen outside of JSON Schema. +# +# Reasons for using the OSM opening hours specification for transportation rule time +# restrictions are documented in https://github.com/OvertureMaps/schema-wg/pull/10 +from typing import Annotated, NewType + +from pydantic import Field + +OpeningHours = NewType( + "OpeningHours", + Annotated[ + str, + Field( + description="Time span or time spans during which something is open or active, specified in the OSM opening hours specification: https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification" + ), + ], +) diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/purpose_of_use.py b/packages/overture-schema-core/src/overture/schema/core/scoping/purpose_of_use.py new file mode 100644 index 000000000..3419f7e76 --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/purpose_of_use.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class PurposeOfUse(str, Enum): + """Reason why a person or entity travelling on the transportation network is using a + particular location.""" + + AS_CUSTOMER = "as_customer" + AT_DESTINATION = "at_destination" + TO_DELIVER = "to_deliver" + TO_FARM = "to_farm" + FOR_FORESTRY = "for_forestry" diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/recognized_status.py b/packages/overture-schema-core/src/overture/schema/core/scoping/recognized_status.py new file mode 100644 index 000000000..12894be07 --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/recognized_status.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class RecognizedStatus(str, Enum): + """Status of the person or entity travelling as recognized by authorities + controlling the particular location.""" + + AS_PERMITTED = "as_permitted" + AS_PRIVATE = "as_private" + AS_DISABLED = "as_disabled" + AS_EMPLOYEE = "as_employee" + AS_STUDENT = "as_student" diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/scoped.py b/packages/overture-schema-core/src/overture/schema/core/scoping/scoped.py new file mode 100644 index 000000000..6ef935f5f --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/scoped.py @@ -0,0 +1,495 @@ +from collections.abc import ( + Callable, + Iterable, +) +from enum import Enum +from typing import ( + Annotated, + Any, + TypedDict, + cast, + get_origin, +) + +from pydantic import ( + BaseModel, + ConfigDict, + Field, +) + +from overture.schema.core.scoping.heading import Heading +from overture.schema.system import create_model +from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import ( + NoExtraFieldsConstraint, + RequireAnyOfConstraint, +) + +from .lr import LinearlyReferencedPosition, LinearlyReferencedRange +from .opening_hours import OpeningHours +from .purpose_of_use import PurposeOfUse +from .recognized_status import RecognizedStatus +from .side import Side +from .travel_mode import TravelMode +from .vehicle import VehicleSelector + + +class Scope(str, Enum): + """ + A scope type supported by the `scoped` decorator. + """ + + GEOMETRIC_POSITION = "geometric_position" + """ + Geometric position scope. (Point linear referencing.) + + This scope type adds an `at` field of type `LinearlyReferencedPosition` to the decorated model. + Use it to build models to capture linearly-referenced point events. + """ + + GEOMETRIC_RANGE = "geometric_range" + """ + Geometric range scope. (Range linear referencing.) + + This scope type adds a `between` field of type `LinearlyReferencedRange` to the decorated model. + Use it to build models to capture linearly-referenced range events. + """ + + HEADING = "heading" + """ + Heading scope. (Direction of facing or travel along a path.) + + This scope adds a `when.heading` field of type `Heading` onto the decorated model. Use it to + build models that apply only when facing or travelling in a certain direction along a path. + + In order to add `when.heading`, this scope will place it into a containing `when` field in the + model it decorates. The type of the `when` field will be a new model class named `When` that + will be added as a nested class of the decorated model. Consequently, the decorated model must + not aready have a `when` field or a nested class named `When`. + """ + + TEMPORAL = "temporal" + """ + Temporal scope. (Applying only at particular times.) + + This scope adds a `when.during` field of type `OpeningHours` to the decorated model. Use it to + build models that apply only during designated times rather than all the time. + + In order to add `when.during`, this scope will place it into a containing `when` field in the + model it decorates. The type of the `when` field will be a new model class named `When` that + will be added as a nested class of the decorated model. Consequently, the decorated model must + not aready have a `when` field or a nested class named `When`. + """ + + TRAVEL_MODE = "travel_mode" + """ + Travel mode scope. (Applying only when travelling a particular way, such as on foot or by car.) + + This scope adds a `when.mode` field of type `list[TravelMode]` to the decorated model. Use it to + build models that apply only to actors who are traveling using one of the listed travel modes. + + In order to add `when.mode`, this scope will add a containing `when` field to the model it + decorates, and then add the `mode` field to the `when` field. Consequently, the decorated + model must not already have a `when` field. + """ + + PURPOSE_OF_USE = "purpose_of_use" + """ + Purpose of use scope. (Applying only when using a something for a specific purpose, such as to + deliver goods, or be a customer.) + + This scope adds a `when.using` field of type `list[PurposeOfUse]` to the decorated model. Use it + to build models that apply only to actors who are doing something for an approved reason. + + In order to add `when.using`, this scope will place it into a containing `when` field in the + model it decorates. The type of the `when` field will be a new model class named `When` that + will be added as a nested class of the decorated model. Consequently, the decorated model must + not aready have a `when` field or a nested class named `When`. + """ + + RECOGNIZED_STATUS = "recognized_status" + """ + Recognized status scope. (Applying only to persons who have an officially recognized status, + such as student or employee.) + + This scope adds a `when.recognized` field of type `list[RecognizedStatus]` to the decorated + model. Use it to build models that apply only to actors who have an approved status. + + In order to add `when.recognized`, this scope will place it into a containing `when` field in + the model it decorates. The type of the `when` field will be a new model class named `When` that + will be added as a nested class of the decorated model. Consequently, the decorated model must + not aready have a `when` field or a nested class named `When`. + """ + + SIDE = "side" + """ + Side scope. (Applying to the left or right side of something, but not both.) + + This scope adds a `side` field of type `Side` to the decorated mode. Use it to build models that + apply exclusively to the left or right side of something. + + Note that for linear features such as roads, the side is based on the geometry orientation: to + an actor facing in the direction of the geometry's orientation, the left side appears on the + actor's left and the right side on the actor's right. + """ + + VEHICLE = "vehicle" + """ + Vehicle scope. (Applying to vehicles with specific characteristics.) + + This scope adds a `when.vehicle` field of type of type `list[VehicleRule]` to the decorated + model. Use it to build models that apply only to certain vehicles based on the listed vehicle + characteristics. + + In order to add `when.vehicle`, this scope will place it into a containing `when` field in the + model it decorates. The type of the `when` field will be a new model class named `When` that + will be added as a nested class of the decorated model. Consequently, the decorated model must + not aready have a `when` field or a nested class named `When`. + """ + + def _field(self, parent: str, required: bool) -> object: + class FieldArgs(TypedDict): + default: object + description: str + min_length: int + + field_args = FieldArgs(description=self._field_description(parent)) # type: ignore[typeddict-item] + + base_type: type[Any] = self._field_type + final_type: Any = self._field_type + + if not required: + final_type = base_type | None + field_args["default"] = None + + annotations: list[object] = [] + is_list_type: bool = ( + isinstance(base_type, list) or get_origin(base_type) is list + ) + if is_list_type: + field_args["min_length"] = 1 + + annotations.append(Field(**field_args)) + + if is_list_type: + annotations.append(UniqueItemsConstraint()) + + args = (final_type, *annotations) + + return Annotated[args] + + @property + def _field_name(self) -> str: + match self: + case Scope.GEOMETRIC_POSITION: + return "at" + case Scope.GEOMETRIC_RANGE: + return "between" + case Scope.HEADING: + return "heading" + case Scope.TEMPORAL: + return "during" + case Scope.TRAVEL_MODE: + return "mode" + case Scope.PURPOSE_OF_USE: + return "using" + case Scope.RECOGNIZED_STATUS: + return "recognized" + case Scope.SIDE: + return "side" + case Scope.VEHICLE: + return "vehicle" + case _: + raise self._unexpected_scope() + + @property + def _field_type(self) -> type[Any]: + match self: + case Scope.GEOMETRIC_POSITION: + return LinearlyReferencedPosition + case Scope.GEOMETRIC_RANGE: + return LinearlyReferencedRange + case Scope.HEADING: + return Heading + case Scope.TEMPORAL: + return OpeningHours + case Scope.TRAVEL_MODE: + return list[TravelMode] + case Scope.PURPOSE_OF_USE: + return list[PurposeOfUse] + case Scope.RECOGNIZED_STATUS: + return list[RecognizedStatus] + case Scope.SIDE: + return Side + case Scope.VEHICLE: + return list[VehicleSelector] + case _: + raise self._unexpected_scope() + + def _field_description(self, parent: str) -> str: + match self: + case Scope.GEOMETRIC_POSITION: + return ( + "The linearly-referenced position on the geometry, " + "specified as a percentage displacement from the start " + f"of the geometry, that the containing {parent} applies to." + ) + case Scope.GEOMETRIC_RANGE: + return ( + "The linearly-referenced sub-segment of the geometry, " + "specified as a range (pair) of percentage displacements " + "from the start of the geometry, that the containing " + f"{parent} applies to." + ) + case Scope.HEADING: + return ( + "The heading, either forward or backward, that the " + f"containing {parent} applies to." + ) + case Scope.TEMPORAL: + return ( + "The recurring time span, in the OpenStreetMap opening " + f"hours format, that the containing {parent} applies to. " + "For the OSM opening hours specification, see " + "https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification." + ) + case Scope.TRAVEL_MODE: + return ( + "A list of one or more travel modes, such as car, truck, " + f"or foot, that the containing {parent} applies to." + ) + case Scope.PURPOSE_OF_USE: + return ( + "A list of one or more usage purposes, such as delivery or " + "arrival at final destination, that the containing " + f"{parent} applies to." + ) + case Scope.RECOGNIZED_STATUS: + return ( + "A list of one or more recognized status values, such as " + f"employee or student, that the containing {parent} " + "applies to." + ) + case Scope.SIDE: + return ( + "The side, either left or right, that the containing " + f"{parent} applies to." + ) + case Scope.VEHICLE: + return ( + "A list of one or more vehicle parameters that limit the " + f"vehicles the containing {parent} applies to." + ) + case _: + raise self._unexpected_scope() + + def _unexpected_scope(self) -> RuntimeError: + return RuntimeError(f"unexpected scope: {self}") + + @staticmethod + def _top_level_scopes() -> tuple["Scope", ...]: + return (Scope.GEOMETRIC_POSITION, Scope.GEOMETRIC_RANGE, Scope.SIDE) + + @staticmethod + def _when_scopes() -> tuple["Scope", ...]: + return ( + Scope.HEADING, + Scope.TEMPORAL, + Scope.TRAVEL_MODE, + Scope.PURPOSE_OF_USE, + Scope.RECOGNIZED_STATUS, + Scope.VEHICLE, + ) + + +def scoped( + *optional: Scope, + required: Scope | Iterable[Scope] | None = None, +) -> Callable: + """ + Returns a decorator to decorate a Pydantic model class with one or more scoping attributes. + + At least one `Scope` must be given either in `optional` or `required` or both. A scope may be + optional or required, but not both. + + Parameters + ---------- + *optional : Scope + Scopes that are allowed, but not required, on the decorated Pydantic model. + required : Scope | Iterable[Scope] | None + Scopes that are required on the decorated Pydantic model. + + Returns + ------- + Callable + Decorator + + Examples + -------- + >>> from pydantic import BaseModel + >>> @scoped(Scope.GEOMETRIC_RANGE, required=Scope.HEADING) + ... class MyModel(BaseModel): + ... pass + >>> MyModel(between=[0.25, 0.75], when=MyModel.When(heading=Heading.FORWARD)) + MyModel(between=[0.25, 0.75], when=MyModel.When(heading=)) + """ + + def collect_scopes( + context: str, scopes: Scope | Iterable[Scope] + ) -> frozenset[Scope]: + if isinstance(scopes, Scope): + return frozenset({scopes}) + elif not isinstance(scopes, Iterable): + raise TypeError( + f"for `@scoped`, {context} must be a `Scope`, or an `Iterable[Scope]` (such as a `list` or `tuple`), but the given value of type {type(scopes).__name__} is none of these" + ) + elif not all(isinstance(s, Scope) for s in scopes): + raise TypeError( + f"for `@scoped`, all elements of `{context}` must be a `Scope`, but at least one value in {repr(scopes)} is not" + ) + else: + return frozenset(cast(Iterable[Scope], scopes)) + + optional_set = collect_scopes("optional", optional) + required_set = collect_scopes("required", required) if required else frozenset() + + if not optional_set and not required_set: + raise ValueError( + "for `@scoped`, at least one scope must be specified, but both `optional` and " + "`required` are empty" + ) + elif optional_set & required_set: + raise ValueError( + "for `@scoped`, `required` must not repeat any values from `optional`, but it has the " + "following repeat values: " + f"{', '.join(sorted(optional_set & required_set))}" + ) + + def decorator(model_class: type[BaseModel]) -> type[BaseModel]: + if not isinstance(model_class, type): + raise TypeError("`@scoped` can only be applied to classes") + if not issubclass(model_class, BaseModel): + raise TypeError( + f"`@scoped` target class must inherit from `{BaseModel.__module__}.{BaseModel.__name__}`" + ) + (new_fields, when_class) = _make_scoped_fields( + model_class, optional_set | required_set, required_set + ) + conflict_fields = sorted( + [f for f in model_class.model_fields.keys() if f in new_fields] + ) + if conflict_fields: + raise TypeError( + f"can't apply `@scoped` to model `{model_class.__name__}`: the following model fields conflict with fields `@scoped` needs to create: {', '.join(conflict_fields)})" + ) + scoped_class = create_model( + model_class.__name__, + __doc__=model_class.__doc__, + __base__=model_class, + __module__=model_class.__module__, + **new_fields, + ) + if when_class: + if hasattr(scoped_class, "When"): + raise TypeError( + f"can't apply `@scoped` to model class `{scoped_class.__name__}`: there is already a class attribute `When` (of type `{type(scoped_class.When).__name__}`)" + ) + scoped_class.When = when_class # type: ignore[attr-defined] + + return scoped_class + + return decorator + + +def _make_scoped_fields( + model_class: type[BaseModel], + allowed: frozenset[Scope], + required: frozenset[Scope], +) -> tuple[dict[str, Any], type[BaseModel] | None]: + parent: str = _model_name(model_class) + + scoped_fields: dict[str, Any] = { + s._field_name: s._field(parent, s in required) + for s in Scope._top_level_scopes() + if s in allowed + } + + when_class: type[BaseModel] | None = None + when_scopes = [s for s in Scope._when_scopes() if s in allowed] + + if when_scopes: + has_required = any(s in required for s in when_scopes) + description = _describe_when(parent, len(scoped_fields) > 1, when_scopes) + + if not has_required and len(when_scopes) == 1: + # If there is exactly one optional `when` field, make `when` optional but the field + # within `when` required. + [s] = when_scopes + when_class = _make_when_class( + model_class, {s._field_name: s._field(parent, True)}, description + ) + scoped_fields["when"] = ( + when_class | None, + Field(default=None, description=description), + ) + else: + when_fields: dict[str, Any] = { + s._field_name: s._field(parent, s in required) for s in when_scopes + } + when_class = _make_when_class(model_class, when_fields, description) + if has_required: + # If any `when` field is required, `when` is itself required. + scoped_fields["when"] = (when_class, Field(description=description)) + else: + # If the `when` has no required fields but contains multiple optional fields, it is + # optional, but if present we require that at least one of its fields be set. + when_class = RequireAnyOfConstraint(*when_fields.keys()).decorate( + when_class + ) + scoped_fields["when"] = ( + when_class | None, + Field(default=None, description=description), + ) + + return (scoped_fields, when_class) + + +def _model_name(model_class: type[BaseModel]) -> str: + return model_class.model_config.get("title") or model_class.__name__ + + +def _describe_when( + parent: str, has_top_level_scopes: bool, when_scopes: list[Scope] +) -> str: + description = "Additional scope" if has_top_level_scopes else "Scope" + if len(when_scopes) > 1: + description += "s" + description = f"{description} for {parent}: " + friendly_names = [str(s).replace("_", " ") for s in when_scopes] + description += ", ".join(friendly_names[:-1]) + if len(when_scopes) > 1: + description += f" and {friendly_names[-1]}" + return description + + +def _make_when_class( + model_class: type[BaseModel], + when_fields: dict[str, Any], + description: str, +) -> type[BaseModel]: + qualname = f"{model_class.__name__}.When" + config = ConfigDict(title=qualname) + if model_class.model_config.get("frozen"): + config["frozen"] = ( + True # Perpetuate parent's immutable/hashable characteristics to "when" clause. + ) + when_class = create_model( + qualname, + __config__=config, + __doc__=description, + __module__=model_class.__module__, + __qualname__=qualname, + **when_fields, + ) + when_class = NoExtraFieldsConstraint().decorate(when_class) + return when_class diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/side.py b/packages/overture-schema-core/src/overture/schema/core/scoping/side.py new file mode 100644 index 000000000..eb218e8a9 --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/side.py @@ -0,0 +1,12 @@ +from overture.schema.system.doc import DocumentedEnum + + +class Side(str, DocumentedEnum): + """ + The side, left or right, on which something appears relative to a facing or heading direction + (*e.g.*, the side of a road relative to the road orientation), or relative to the direction of + travel of a person or vehicle. + """ + + LEFT = ("left", "On the left relative to the facing direction") + RIGHT = ("right", "On the right side relative to the facing direction") diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/travel_mode.py b/packages/overture-schema-core/src/overture/schema/core/scoping/travel_mode.py new file mode 100644 index 000000000..6de69e23e --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/travel_mode.py @@ -0,0 +1,20 @@ +from enum import Enum + + +class TravelMode(str, Enum): + """Enumerates possible travel modes. + + Some modes represent groups of modes. + """ + + VEHICLE = "vehicle" + MOTOR_VEHICLE = "motor_vehicle" # includes car, truck and motorcycle + CAR = "car" + TRUCK = "truck" + MOTORCYCLE = "motorcycle" + FOOT = "foot" + BICYCLE = "bicycle" + BUS = "bus" + HGV = "hgv" + HOV = "hov" + EMERGENCY = "emergency" diff --git a/packages/overture-schema-core/src/overture/schema/core/scoping/vehicle.py b/packages/overture-schema-core/src/overture/schema/core/scoping/vehicle.py new file mode 100644 index 000000000..f5746808b --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/scoping/vehicle.py @@ -0,0 +1,127 @@ +from enum import Enum +from typing import Annotated, Literal + +from pydantic import BaseModel, Field + +from overture.schema.core.unit import LengthUnit, WeightUnit +from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.primitive import float32, uint8 + + +class VehicleDimension(str, Enum): + """ + Dimension of a vehicle, such as length, weight, or number of axles, that can be constrained in a + `VehicleParameter`. + + See also: `overture.schema.core.scoping.Scope.VEHICLE`. + """ + + AXLE_COUNT = "axle_count" + HEIGHT = "height" + LENGTH = "length" + WEIGHT = "weight" + WIDTH = "width" + + +class VehicleRelation(str, Enum): + """Relational operator, such as less than or equal to.""" + + GREATER_THAN = "greater_than" + GREATER_THAN_EQUAL = "greater_than_equal" + EQUAL = "equal" + LESS_THAN = "less_than" + LESS_THAN_EQUAL = "less_than_equal" + + +@no_extra_fields +class VehicleAxleCountSelector(BaseModel): + """ + Selects vehicles based on the number of axles they have. + """ + + dimension: Literal[VehicleDimension.AXLE_COUNT] + comparison: VehicleRelation + value: uint8 = Field(description="Number of axles on the vehicle") + + +@no_extra_fields +class VehicleHeightSelector(BaseModel): + """ + Selects vehicles based on their height. + """ + + dimension: Literal[VehicleDimension.HEIGHT] + comparison: VehicleRelation + value: Annotated[ + float32, + Field( + ge=0, decription="Vehicle height selection threshold in the given `unit`" + ), + ] + unit: LengthUnit = Field(description="Height unit in which `value` is expressed") + + +@no_extra_fields +class VehicleLengthSelector(BaseModel): + """ + Selects vehicles based on their length. + """ + + dimension: Literal[VehicleDimension.LENGTH] + comparison: VehicleRelation + value: Annotated[ + float32, + Field( + ge=0, description="Vehicle length selection threshold in the given `unit`" + ), + ] + unit: LengthUnit = Field(description="Length unit in which `value` is expressed") + + +@no_extra_fields +class VehicleWeightSelector(BaseModel): + """ + Selects vehicles based on their weight. + """ + + dimension: Literal[VehicleDimension.WEIGHT] + comparison: VehicleRelation + value: Annotated[ + float32, + Field( + ge=0, description="Vehicle weight selection threshold in the given `unit`" + ), + ] + unit: WeightUnit = Field(description="Weight unit in which `value` is expressed") + + +@no_extra_fields +class VehicleWidthSelector(BaseModel): + """ + Selects vehicles based on their width. + """ + + dimension: Literal[VehicleDimension.WIDTH] + comparison: VehicleRelation + value: Annotated[ + float32, + Field( + ge=0, description="Vehicle width selection threshold in the given `unit`" + ), + ] + unit: LengthUnit = Field(description="Width unit in which `value` is expressed") + + +VehicleSelector = Annotated[ + VehicleAxleCountSelector + | VehicleHeightSelector + | VehicleLengthSelector + | VehicleWeightSelector + | VehicleWidthSelector, + Field( + description="Selects vehicles that a scope applies to based on criteria such as height, weight, or axle count." + ), +] +""" +Selects vehicles that a scope applies to based on criteria such as height, weigh, or axle count. +""" diff --git a/packages/overture-schema-core/src/overture/schema/core/types.py b/packages/overture-schema-core/src/overture/schema/core/types.py index e66b0506e..e4c3f3a35 100644 --- a/packages/overture-schema-core/src/overture/schema/core/types.py +++ b/packages/overture-schema-core/src/overture/schema/core/types.py @@ -5,20 +5,13 @@ Field, GetCoreSchemaHandler, GetJsonSchemaHandler, - ValidationError, - ValidationInfo, ) -from pydantic_core import InitErrorDetails, core_schema +from pydantic_core import core_schema from overture.schema.system.field_constraint import ( - CollectionConstraint, FieldConstraint, ) -from overture.schema.system.primitive import float32, int32, pct -from overture.schema.system.string import ( - LanguageTag, - StrippedString, -) +from overture.schema.system.primitive import float32, int32 class ConfidenceScoreConstraint(FieldConstraint): @@ -52,115 +45,6 @@ def __get_pydantic_json_schema__( ) -# One possible advantage to using percentages over absolute distances is being able to -# trivially validate that the position lies "on" its segment (i.e. is between zero and -# one). Of course, this level of validity doesn't mean the number isn't nonsense -LinearlyReferencedPosition = NewType( - "LinearlyReferencedPosition", - Annotated[ - pct, - Field( - description="Represents a linearly-referenced position between 0% and 100% of the distance along a path such as a road segment or a river center-line segment.", - ), - ], -) - - -class LinearReferenceRangeConstraint(CollectionConstraint): - """Linear reference range constraint (0.0 to 1.0).""" - - def validate(self, value: list[float], info: ValidationInfo) -> None: - if len(value) != 2: - context = info.context or {} - loc = context.get("loc_prefix", ()) + ("value",) - raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=[ - InitErrorDetails( - type="value_error", - loc=loc, - input=value, - ctx={ - "error": f"Linear reference range must have exactly 2 values, got {len(value)}" - }, - ) - ], - ) - - start, end = value - if not (0.0 <= start <= 1.0 and 0.0 <= end <= 1.0): - context = info.context or {} - loc = context.get("loc_prefix", ()) + ("value",) - raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=[ - InitErrorDetails( - type="value_error", - loc=loc, - input=value, - ctx={ - "error": f"Linear reference range values must be between 0.0 and 1.0: [{start}, {end}]" - }, - ) - ], - ) - - if start >= end: - context = info.context or {} - loc = context.get("loc_prefix", ()) + ("value",) - raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=[ - InitErrorDetails( - type="value_error", - loc=loc, - input=value, - ctx={ - "error": f"Linear reference range start must be less than end: [{start}, {end}]" - }, - ) - ], - ) - - def __get_pydantic_json_schema__( - self, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler - ) -> dict[str, Any]: - json_schema = handler(core_schema) - json_schema["type"] = "array" - json_schema["minItems"] = 2 - json_schema["maxItems"] = 2 - json_schema["items"] = {"type": "number", "minimum": 0.0, "maximum": 1.0} - json_schema["description"] = ( - "Linear reference range [start, end] where 0.0 <= start < end <= 1.0" - ) - return json_schema - - -LinearlyReferencedRange = NewType( - "LinearlyReferencedRange", - Annotated[ - list[LinearlyReferencedPosition], - LinearReferenceRangeConstraint(), - Field( - description="Represents a non-empty range of positions along a path as a pair linearly-referenced positions. For example, the pair [0.25, 0.5] represents the range beginning 25% of the distance from the start of the path and ending 50% of the distance from the path", - ), - ], -) - -# Validating the opening hours value is going to have to happen outside of JSON Schema. -# -# Reasons for using the OSM opening hours specification for transportation rule time -# restrictions are documented in https://github.com/OvertureMaps/schema-wg/pull/10 -OpeningHours = NewType( - "OpeningHours", - Annotated[ - str, - Field( - description="Time span or time spans during which something is open or active, specified in the OSM opening hours specification: https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification" - ), - ], -) - Level = NewType( "Level", Annotated[ @@ -169,24 +53,6 @@ def __get_pydantic_json_schema__( ], ) -CommonNames = NewType( - "CommonNames", - Annotated[ - dict[ - Annotated[ - LanguageTag, - Field( - description="""Each entry consists of a key that is an IETF-BCP47 language tag; and a value that reflects the common name in the language represented by the key's language tag. - -The validating regular expression for this property follows the pattern described in https://www.rfc-editor.org/rfc/bcp/bcp47.txt with the exception that private use tags are not supported.""" - ), - ], - StrippedString, - ], - Field(json_schema_extra={"additionalProperties": False}), - ], -) - # It might be reasonable to combine "update_time" and "version" in a single # "updateVersion" field which gives the last Overture version number in which the # feature changed. The downside to doing this is that the number would cease to be @@ -274,17 +140,12 @@ def __get_pydantic_json_schema__( Type = Annotated[str, Field(description="Specific feature type within the theme")] __all__ = [ - "CommonNames", "ConfidenceScore", "FeatureUpdateTime", "FeatureVersion", "Level", - "LinearlyReferencedPosition", - "LinearlyReferencedRange", - "LinearReferenceRangeConstraint", "MaxZoom", "MinZoom", - "OpeningHours", "Prominence", "SortKey", "Theme", diff --git a/packages/overture-schema-core/src/overture/schema/core/unit.py b/packages/overture-schema-core/src/overture/schema/core/unit.py new file mode 100644 index 000000000..109327cec --- /dev/null +++ b/packages/overture-schema-core/src/overture/schema/core/unit.py @@ -0,0 +1,42 @@ +from enum import Enum + + +class SpeedUnit(str, Enum): + """Unit of speed.""" + + MPH = "mph" + KPH = "km/h" + + +class LengthUnit(str, Enum): + """Unit of length.""" + + # Keep in sync with `combobulib/measure.py`. + + # Imperial units. + IN = "in" # Imperial: Inch. + FT = "ft" # Imperial: Foot. + YD = "yd" # Imperial: Yard. + MI = "mi" # Imperial: Mile. + + # SI units. + CM = "cm" # SI: centimeter. + M = "m" # SI: meter. + KM = "km" # SI: kilometer. + + +class WeightUnit(str, Enum): + """Unit of weight.""" + + # Keep in sync with `combobulib/measure.py`. + + # Imperial units. + OZ = "oz" # Imperial: Ounce. + LB = "lb" # Imperial: Pound. + ST = "st" # Imperial: Short Ton. + LT = "lt" # Imperial: Long Ton. + + # SI units. + G = "g" # SI: gram. + KG = "kg" # SI: kilogram. + T = "t" # SI: tonne. diff --git a/packages/overture-schema-core/tests/scoping/test_scoped.py b/packages/overture-schema-core/tests/scoping/test_scoped.py new file mode 100644 index 000000000..4bacbfdfc --- /dev/null +++ b/packages/overture-schema-core/tests/scoping/test_scoped.py @@ -0,0 +1,305 @@ +import itertools +import re +from typing import Annotated, cast, get_args, get_origin + +import pytest +from overture.schema.core.scoping import ( + Heading, + LinearlyReferencedPosition, + Scope, + Side, + TravelMode, + VehicleSelector, + scoped, +) +from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import ( + ModelConstraint, + RequireAnyOfConstraint, +) +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + + +class TestScope: + @pytest.mark.parametrize("scope", list(Scope)) + def test__field_optional(self, scope: Scope) -> None: + field = scope._field("foo", False) + origin = get_origin(field) + args = get_args(field) + + assert origin is Annotated + assert isinstance(args[1], FieldInfo) + assert args[1].default is None + + @pytest.mark.parametrize("scope", list(Scope)) + def test__field_required(self, scope: Scope) -> None: + field = scope._field("bar", True) + origin = get_origin(field) + args = get_args(field) + + assert origin is Annotated + assert isinstance(args[1], FieldInfo) + assert args[1].default is PydanticUndefined + + +class TestScoped: + def test_error_applied_to_non_class(self) -> None: + with pytest.raises(TypeError, match="`@scoped` can only be applied to classes"): + + class Foo: + @scoped(Scope.GEOMETRIC_POSITION) + def bar(self) -> int: + return 123 + + def test_error_applied_to_non_base_model(self) -> None: + with pytest.raises( + TypeError, + match="`@scoped` target class must inherit from `pydantic.main.BaseModel`", + ): + + @scoped(Scope.HEADING) + class Baz: + pass + + @pytest.mark.parametrize("scope", Scope._top_level_scopes()) + def test_error_conflicting_model_field_top_level(self, scope: Scope) -> None: + with pytest.raises( + TypeError, + match=f"can't apply `@scoped` to model `Qux`: the following model fields conflict with fields `@scoped` needs to create: {scope._field_name}", + ): + + @scoped(scope) + class Qux(BaseModel): + exec(f"{scope._field_name}: int = 42") + + @pytest.mark.parametrize("scope", Scope._when_scopes()) + def test_error_conflicting_model_field_when(self, scope: Scope) -> None: + with pytest.raises( + TypeError, + match="can't apply `@scoped` to model `Corge`: the following model fields conflict with fields `@scoped` needs to create: when", + ): + + @scoped(scope) + class Corge(BaseModel): + when: int = 42 + + @pytest.mark.parametrize("scope", Scope._when_scopes()) + def test_error_model_already_has_When_attribute(self, scope: Scope) -> None: + with pytest.raises( + TypeError, + match="can't apply `@scoped` to model class `Garply`: there is already a class attribute `When`", + ): + + @scoped(scope) + class Garply(BaseModel): + class When: + pass + + def test_error_no_scopes(self) -> None: + with pytest.raises( + ValueError, + match="for `@scoped`, at least one scope must be specified, but both `optional` and `required` are empty", + ): + + @scoped() + class Foo(BaseModel): + pass + + def test_error_repeated_scopes(self) -> None: + with pytest.raises( + ValueError, + match="for `@scoped`, `required` must not repeat any values from `optional`, but it has the following repeat values: temporal", + ): + + @scoped(Scope.TEMPORAL, required=Scope.TEMPORAL) + class Bar(BaseModel): + pass + + def test_error_required_not_scope_or_iterable(self) -> None: + with pytest.raises( + TypeError, + match=re.escape( + "for `@scoped`, required must be a `Scope`, or an `Iterable[Scope]` (such as a `list` or `tuple`), but the given value of type int is none of these" + ), + ): + + @scoped(required=cast(Scope, 42)) + class Baz(BaseModel): + pass + + def test_error_required_iterable_non_scope(self) -> None: + with pytest.raises( + TypeError, + match=re.escape( + "for `@scoped`, all elements of `required` must be a `Scope`, but at least one value in (, 42) is not" + ), + ): + + @scoped(required=(Scope.SIDE, cast(Scope, 42))) + class Qux(BaseModel): + pass + + @pytest.mark.parametrize( + "scope,required", itertools.product(Scope._top_level_scopes(), (False, True)) + ) + def test_single_scope_top_level(self, scope: Scope, required: bool) -> None: + if required: + o = [] + r = [scope] + else: + o = [scope] + r = [] + + @scoped(*o, required=r) + class SingleScoped(BaseModel): + pass + + assert len(SingleScoped.model_fields) == 1 + + field_info = SingleScoped.model_fields[scope._field_name] + assert field_info.is_required() == required + + @pytest.mark.parametrize( + "scope,required", itertools.product(Scope._when_scopes(), (False, True)) + ) + def test_single_scope_when(self, scope: Scope, required: bool) -> None: + if required: + o = [] + r = [scope] + else: + o = [scope] + r = [] + + @scoped(*o, required=r) + class SingleScoped(BaseModel): + pass + + assert len(SingleScoped.model_fields) == 1 + + when_field_info = SingleScoped.model_fields["when"] + assert when_field_info.is_required() == required + + when_class = SingleScoped.When + assert issubclass(when_class, BaseModel) + assert len(when_class.model_fields) == 1 + + scoped_field_info = when_class.model_fields[scope._field_name] + assert ( + scoped_field_info.is_required() + ) # If a single `when` field is optional, the `when` is optional. + + def test_multi_scope_when_all_optional(self) -> None: + @scoped(Scope.TRAVEL_MODE, Scope.VEHICLE) + class MultiScopedWhenAllFieldsOptional(BaseModel): + pass + + assert len(MultiScopedWhenAllFieldsOptional.model_fields) == 1 + + when_field_info = MultiScopedWhenAllFieldsOptional.model_fields["when"] + assert not when_field_info.is_required() + + when_class = MultiScopedWhenAllFieldsOptional.When + assert issubclass(when_class, BaseModel) + assert len(when_class.model_fields) == 2 + + travel_mode_field_info = when_class.model_fields[Scope.TRAVEL_MODE._field_name] + assert not travel_mode_field_info.is_required() + + vehicle_field_info = when_class.model_fields[Scope.VEHICLE._field_name] + assert not vehicle_field_info.is_required() + + model_constraints = ModelConstraint.get_model_constraints(when_class) + require_any_of = next( + c for c in model_constraints if isinstance(c, RequireAnyOfConstraint) + ) + assert sorted(require_any_of.field_names) == [ + Scope.TRAVEL_MODE._field_name, + Scope.VEHICLE._field_name, + ] + + def test_multi_scope_when_some_required(self) -> None: + @scoped(Scope.RECOGNIZED_STATUS, required=(Scope.PURPOSE_OF_USE, Scope.VEHICLE)) + class MultiScopedWhenSomeFieldsRequired(BaseModel): + pass + + assert len(MultiScopedWhenSomeFieldsRequired.model_fields) == 1 + + when_field_info = MultiScopedWhenSomeFieldsRequired.model_fields["when"] + assert when_field_info.is_required() + + when_class = MultiScopedWhenSomeFieldsRequired.When + assert issubclass(when_class, BaseModel) + assert len(when_class.model_fields) == 3 + + recognized_status_field_info = when_class.model_fields[ + Scope.RECOGNIZED_STATUS._field_name + ] + assert not recognized_status_field_info.is_required() + + purpose_of_use_field_info = when_class.model_fields[ + Scope.PURPOSE_OF_USE._field_name + ] + assert purpose_of_use_field_info.is_required() + + vehicle_field_info = when_class.model_fields[Scope.VEHICLE._field_name] + assert vehicle_field_info.is_required() + + def test_complex_scope(self) -> None: + @scoped( + Scope.GEOMETRIC_POSITION, + Scope.HEADING, + Scope.VEHICLE, + required=[Scope.TRAVEL_MODE, Scope.SIDE], + ) + class Complex(BaseModel): + pass + + assert len(Complex.model_fields) == 3 + + geometric_position_field_info = Complex.model_fields[ + Scope.GEOMETRIC_POSITION._field_name + ] + assert not geometric_position_field_info.is_required() + assert ( + get_args(geometric_position_field_info.annotation)[0] + is LinearlyReferencedPosition + ) + + when_field_info = Complex.model_fields["when"] + assert when_field_info.is_required() + + when_class = Complex.When + assert issubclass(when_class, BaseModel) + assert len(when_class.model_fields) == 3 + + heading_field_info = when_class.model_fields[Scope.HEADING._field_name] + assert not heading_field_info.is_required() + assert get_args(heading_field_info.annotation)[0] is Heading + + vehicle_field_info = when_class.model_fields[Scope.VEHICLE._field_name] + assert not vehicle_field_info.is_required() + vehicle_field_type = get_args(vehicle_field_info.annotation)[0] + assert get_origin(vehicle_field_type) is list + assert get_args(vehicle_field_type)[0] is VehicleSelector + assert any( + x + for x in vehicle_field_info.metadata + if isinstance(x, UniqueItemsConstraint) + ) + + travel_mode_field_info = when_class.model_fields[Scope.TRAVEL_MODE._field_name] + assert travel_mode_field_info.is_required() + travel_mode_field_type = travel_mode_field_info.annotation + assert get_origin(travel_mode_field_type) is list + assert get_args(travel_mode_field_type)[0] is TravelMode + assert any( + x + for x in travel_mode_field_info.metadata + if isinstance(x, UniqueItemsConstraint) + ) + + side_field_info = Complex.model_fields[Scope.SIDE._field_name] + assert side_field_info.is_required() + assert side_field_info.annotation is Side diff --git a/packages/overture-schema-core/tests/test_scoping.py b/packages/overture-schema-core/tests/test_scoping.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/overture-schema-core/tests/test_types.py b/packages/overture-schema-core/tests/test_types.py index f9921bab1..290b55476 100644 --- a/packages/overture-schema-core/tests/test_types.py +++ b/packages/overture-schema-core/tests/test_types.py @@ -1,9 +1,9 @@ from typing import Annotated import pytest +from overture.schema.core.scoping.lr import LinearReferenceRangeConstraint from overture.schema.core.types import ( ConfidenceScoreConstraint, - LinearReferenceRangeConstraint, ) from pydantic import BaseModel, ValidationError diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py index c34ba82d7..fb5d4bbfe 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py @@ -7,14 +7,12 @@ from overture.schema.core import ( OvertureFeature, ) -from overture.schema.core.enums import Side from overture.schema.core.models import ( CartographicallyHinted, - Named, - Names, Perspectives, ) -from overture.schema.core.types import CommonNames +from overture.schema.core.names import CommonNames, Named, Names +from overture.schema.core.scoping.side import Side from overture.schema.system.field_constraint import ( UniqueItemsConstraint, ) diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py index 0705fd281..5519b40e4 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py @@ -7,7 +7,7 @@ from overture.schema.core import ( OvertureFeature, ) -from overture.schema.core.models import ( +from overture.schema.core.names import ( Named, Names, ) diff --git a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json index e099f81f3..9ebf27707 100644 --- a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json @@ -14,7 +14,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -36,7 +36,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -166,7 +167,7 @@ "type": "string" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json index 69422773b..0cc3005b2 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -103,7 +103,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -125,7 +125,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -267,7 +268,7 @@ "type": "string" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" diff --git a/packages/overture-schema-places-theme/pyproject.toml b/packages/overture-schema-places-theme/pyproject.toml index 31500f7a5..67dc42302 100644 --- a/packages/overture-schema-places-theme/pyproject.toml +++ b/packages/overture-schema-places-theme/pyproject.toml @@ -27,4 +27,4 @@ path = "src/overture/schema/places/__about__.py" packages = ["src/overture"] [project.entry-points."overture.models"] -"places.place" = "overture.schema.places.place.models:Place" +"places.place" = "overture.schema.places:Place" diff --git a/packages/overture-schema-places-theme/src/overture/schema/places/place.py b/packages/overture-schema-places-theme/src/overture/schema/places/place.py new file mode 100644 index 000000000..595c46db7 --- /dev/null +++ b/packages/overture-schema-places-theme/src/overture/schema/places/place.py @@ -0,0 +1,222 @@ +"""Place feature models for Overture Maps places theme.""" + +import textwrap +from enum import Enum +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl + +from overture.schema.core import ( + OvertureFeature, +) +from overture.schema.core.names import ( + Named, +) +from overture.schema.core.types import ( + ConfidenceScore, +) +from overture.schema.system.field_constraint import ( + UniqueItemsConstraint, +) +from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.primitive import ( + Geometry, + GeometryType, + GeometryTypeConstraint, +) +from overture.schema.system.string import ( + CountryCodeAlpha2, + PhoneNumber, + RegionCode, + SnakeCaseString, + WikidataId, +) + + +class OperatingStatus(str, Enum): + """ + General indication of whether a place is: in continued operation, in a temporary operating + hiatus, or closed permanently. + + Operating status should not be confused with opening hours or operating hours. In particular, + the status `"open"` does not mean the place is open *right now*, it means that in general the + place is continuing to operate normally, as opposed to being in an operating hiatus + (`"temporarily_closed"`) or shuttered (`"permanently_closed"`). + """ + + OPEN = "open" + PERMANENTLY_CLOSED = "permanently_closed" + TEMPORARILY_CLOSED = "temporarily_closed" + + +@no_extra_fields +class Categories(BaseModel): + """ + Categories a place belongs to. + + Complete list is available on GitHub: https://github.com/OvertureMaps/schema/blob/main/docs/schema/concepts/by-theme/places/overture_categories.csv + """ + + # Required + + primary: Annotated[ + SnakeCaseString, + Field(description="The primary or main category of the place."), + ] + + # Optional + + alternate: Annotated[ + list[SnakeCaseString] | None, + Field( + description=textwrap.dedent(""" + Alternate categories of the place. + + Some places might fit into two categories, e.g., a book store and a coffee shop. In + these cases, the primary category can be augmented with additional categories. + """).strip(), + ), + UniqueItemsConstraint(), + ] = None + + +@no_extra_fields +class Brand(Named): + """ + A brand associated with a place. + + A location with multiple brands is modeled as multiple separate places, each with its own brand. + """ + + # Optional + + wikidata: WikidataId | None = None + + +@no_extra_fields +class Address(BaseModel): + """ + An address associated with a place. + """ + + # Optional + + freeform: Annotated[ + str | None, + Field( + description="Free-form address that contains street name, house number and other address info", + ), + ] = None + locality: Annotated[ + str | None, + Field( + description="City, town, or neighborhood component of the place address", + ), + ] = None + postcode: Annotated[ + str | None, Field(description="Postal code component of the place address") + ] = None + region: RegionCode | None = None + country: CountryCodeAlpha2 | None = None + + +class Place(OvertureFeature[Literal["places"], Literal["place"]], Named): + """ + A Place is a point representation of a real-world facility, service, or amenity. + """ + + model_config = ConfigDict(title="place") + + # Required + + geometry: Annotated[ + Geometry, + GeometryTypeConstraint(GeometryType.POINT), + Field( + description="Position of the place. Places are point geometries.", + ), + ] + operating_status: Annotated[ + OperatingStatus, + Field( + description=textwrap.dedent(""" + An indication of whether a place is: in continued operation, in a temporary + operating hiatus, or closed permanently. + + This is not an indication of opening hours or that the place is open/closed at the + current time-of-day or day-of-week. + + When `operating_status` is `"permanently_closed"`, the `confidence` field will be + set to 0. + """).strip(), + ), + ] + + # Optional + + categories: Categories | None = None + basic_category: Annotated[ + SnakeCaseString | None, + Field( + description=textwrap.dedent( + """ + The basic level category of a place. + + This field classifies places into categories at a level that most people find + intuitive. The full list of possible values it may hold can be found at (TODO). + + The basic level category, or simply basic category, is based on a cognitive science + model use in taxonomy and ontology development. The idea is to provide the category + name at the level of generality that is preferred by humans in learning and memory + tasks. This category to be roughly in the middle of the general-to-specific category + hierarchy. + """ + ).strip() + ), + ] = None + confidence: Annotated[ + ConfidenceScore | None, + Field( + description=textwrap.dedent( + """ + A score between 0 and 1 indicating how confident we are that the place exists. + + A confidence score of 0 indicates that we are certain the place doesn't exist + anymore and will always be paired with an `operating_status` of + `"permanently_closed"`. + + A confidence score of 1 indicates that we are certain the place does exist. + + If there is no value for confidence, it means we don't have enough information on + which to estimate our confidence level. + """ + ).strip(), + ), + ] = None + websites: Annotated[ + list[HttpUrl] | None, + Field(min_length=1, description="The websites of the place."), + UniqueItemsConstraint(), + ] = None + socials: Annotated[ + list[HttpUrl] | None, + Field(min_length=1, description="The social media URLs of the place."), + UniqueItemsConstraint(), + ] = None + emails: Annotated[ + list[EmailStr] | None, + Field(min_length=1, description="The email addresses of the place."), + UniqueItemsConstraint(), + ] = None + phones: Annotated[ + list[PhoneNumber] | None, + Field(min_length=1, description="The phone numbers of the place."), + UniqueItemsConstraint(), + ] = None + brand: Annotated[ + Brand | None, Field(description="The brand associated with the place.") + ] = None + addresses: Annotated[ + list[Address] | None, + Field(min_length=1, description="The address or addresses of the place"), + ] = None diff --git a/packages/overture-schema-places-theme/src/overture/schema/places/place/__init__.py b/packages/overture-schema-places-theme/src/overture/schema/places/place/__init__.py deleted file mode 100644 index b1724b162..000000000 --- a/packages/overture-schema-places-theme/src/overture/schema/places/place/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .models import ( - Place, -) - -__all__ = [ - "Place", -] diff --git a/packages/overture-schema-places-theme/src/overture/schema/places/place/enums.py b/packages/overture-schema-places-theme/src/overture/schema/places/place/enums.py deleted file mode 100644 index 6cb3230ad..000000000 --- a/packages/overture-schema-places-theme/src/overture/schema/places/place/enums.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import Enum - - -class OperatingStatus(str, Enum): - OPEN = "open" - PERMANENTLY_CLOSED = "permanently_closed" - TEMPORARILY_CLOSED = "temporarily_closed" diff --git a/packages/overture-schema-places-theme/src/overture/schema/places/place/models.py b/packages/overture-schema-places-theme/src/overture/schema/places/place/models.py deleted file mode 100644 index c4f8b617b..000000000 --- a/packages/overture-schema-places-theme/src/overture/schema/places/place/models.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Place feature models for Overture Maps places theme.""" - -from typing import Annotated, Literal - -from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl - -from overture.schema.core import ( - OvertureFeature, -) -from overture.schema.core.models import ( - Address, - Named, -) -from overture.schema.core.types import ( - ConfidenceScore, -) -from overture.schema.system.field_constraint import ( - UniqueItemsConstraint, -) -from overture.schema.system.model_constraint import no_extra_fields -from overture.schema.system.primitive import ( - Geometry, - GeometryType, - GeometryTypeConstraint, -) -from overture.schema.system.string import PhoneNumber, WikidataId - -from ..types import SnakeCaseString -from .enums import OperatingStatus - - -@no_extra_fields -class Categories(BaseModel): - """The categories of the place. - - Complete list is available on - GitHub: https://github.com/OvertureMaps/schema/blob/main/docs/schema/concepts/by-theme/places/overture_categories.csv - """ - - # Required - - primary: Annotated[ - SnakeCaseString, - Field( - description="The primary or main category of the place. This can be empty." - ), - ] - - # Optional - - alternate: Annotated[ - list[SnakeCaseString] | None, - Field( - description="""Alternate categories of the place. Some places might fit into two categories, e.g. a book store and a coffee shop. In such a case, the primary category can be augmented with additional applicable categories.""", - ), - UniqueItemsConstraint(), - ] = None - - -@no_extra_fields -class Brand(Named): - """The brand of the place. - - A location with multiple brands is modeled as multiple separate places, each with - its own brand. - """ - - # Optional - - wikidata: WikidataId | None = None - - -class Place(OvertureFeature[Literal["places"], Literal["place"]], Named): - """A Place is a point representation of a real-world facility, service, or amenity. - - Place features are compatible with GeoJSON Point features. - """ - - model_config = ConfigDict(title="place") - - # Required - geometry: Annotated[ - Geometry, - GeometryTypeConstraint(GeometryType.POINT), - Field( - description="Position of the place", - ), - ] - operating_status: Annotated[ - OperatingStatus, - Field( - description="""Indicates the operating status of a place, can be one of ["open", "permanently_closed", "temporarily_closed"]. -This is not an indication of 'opening hours' or that the place is open/closed at the current time-of-day or day-of-week.""", - ), - ] - - # Optional - - categories: Categories | None = None - basic_category: Annotated[ - SnakeCaseString | None, - Field( - description="""The basic level category of a place. At present this is a mapping of the categories.primary entry to a new, simplified name. This mapping can be 1:1 or M:1 from the existing primary.categories entry. If the entry is currently empty in categories.primary, this entry will be empty. This type of categorization is a cognitive science model that is relevant for taxonomy and ontology development that shows the most broadest and general category name that is most often found in the middle of a general-to-specific hierarchy, with generalization proceeding upward and specialization proceeding downward. The full list of basic level categories is available at:(todo)""" - ), - ] = None - confidence: Annotated[ - ConfidenceScore | None, - Field( - description="""The confidence of the existence of the place. It's a number between 0 and 1. 0 means that we're sure that the place doesn't exist (anymore). 1 means that we're sure that the place exists. If there's no value for the confidence, it means that we don't have any confidence information. Places with operating_status set to 'closed' will have a confidence score of 0""", - ), - ] = None - websites: Annotated[ - list[HttpUrl] | None, - Field(min_length=1, description="The websites of the place."), - UniqueItemsConstraint(), - ] = None - socials: Annotated[ - list[HttpUrl] | None, - Field(min_length=1, description="The social media URLs of the place."), - UniqueItemsConstraint(), - ] = None - emails: Annotated[ - list[EmailStr] | None, - Field(min_length=1, description="The email addresses of the place."), - UniqueItemsConstraint(), - ] = None - phones: Annotated[ - list[PhoneNumber] | None, - Field(min_length=1, description="The phone numbers of the place."), - UniqueItemsConstraint(), - ] = None - brand: Brand | None = None - addresses: Annotated[list[Address] | None, Field(min_length=1)] = None diff --git a/packages/overture-schema-places-theme/src/overture/schema/places/types.py b/packages/overture-schema-places-theme/src/overture/schema/places/types.py deleted file mode 100644 index b01623f05..000000000 --- a/packages/overture-schema-places-theme/src/overture/schema/places/types.py +++ /dev/null @@ -1,3 +0,0 @@ -from overture.schema.system.string import SnakeCaseString - -__all__ = ["SnakeCaseString"] diff --git a/packages/overture-schema-places-theme/tests/place_baseline_schema.json b/packages/overture-schema-places-theme/tests/place_baseline_schema.json index be50ee6ad..790c4e477 100644 --- a/packages/overture-schema-places-theme/tests/place_baseline_schema.json +++ b/packages/overture-schema-places-theme/tests/place_baseline_schema.json @@ -2,6 +2,7 @@ "$defs": { "Address": { "additionalProperties": false, + "description": "An address associated with a place.", "properties": { "country": { "description": "ISO 3166-1 alpha-2 country code", @@ -17,12 +18,12 @@ "type": "string" }, "locality": { - "description": "Name of the city or neighborhood where the address is located", + "description": "City, town, or neighborhood component of the place address", "title": "Locality", "type": "string" }, "postcode": { - "description": "Postal code where the address is located", + "description": "Postal code component of the place address", "title": "Postcode", "type": "string" }, @@ -40,7 +41,7 @@ }, "Brand": { "additionalProperties": false, - "description": "The brand of the place.\n\nA location with multiple brands is modeled as multiple separate places, each with\nits own brand.", + "description": "A brand associated with a place.\n\nA location with multiple brands is modeled as multiple separate places, each with its own brand.", "properties": { "names": { "$ref": "#/$defs/Names" @@ -57,10 +58,10 @@ }, "Categories": { "additionalProperties": false, - "description": "The categories of the place.\n\nComplete list is available on\nGitHub: https://github.com/OvertureMaps/schema/blob/main/docs/schema/concepts/by-theme/places/overture_categories.csv", + "description": "Categories a place belongs to.\n\nComplete list is available on GitHub: https://github.com/OvertureMaps/schema/blob/main/docs/schema/concepts/by-theme/places/overture_categories.csv", "properties": { "alternate": { - "description": "Alternate categories of the place. Some places might fit into two categories, e.g. a book store and a coffee shop. In such a case, the primary category can be augmented with additional applicable categories.", + "description": "Alternate categories of the place.\n\nSome places might fit into two categories, e.g., a book store and a coffee shop. In\nthese cases, the primary category can be augmented with additional categories.", "items": { "description": "Category in snake_case format", "pattern": "^[a-z0-9]+(_[a-z0-9]+)*$", @@ -71,7 +72,7 @@ "uniqueItems": true }, "primary": { - "description": "The primary or main category of the place. This can be empty.", + "description": "The primary or main category of the place.", "pattern": "^[a-z0-9]+(_[a-z0-9]+)*$", "title": "Primary", "type": "string" @@ -88,7 +89,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -110,7 +111,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -182,6 +184,7 @@ "type": "object" }, "OperatingStatus": { + "description": "General indication of whether a place is: in continued operation, in a temporary operating\nhiatus, or closed permanently.\n\nOperating status should not be confused with opening hours or operating hours. In particular,\nthe status `\"open\"` does not mean the place is open *right now*, it means that in general the\nplace is continuing to operate normally, as opposed to being in an operating hiatus\n(`\"temporarily_closed\"`) or shuttered (`\"permanently_closed\"`).", "enum": [ "open", "permanently_closed", @@ -230,7 +233,7 @@ "type": "object" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" @@ -297,7 +300,7 @@ } }, "additionalProperties": false, - "description": "A Place is a point representation of a real-world facility, service, or amenity.\n\nPlace features are compatible with GeoJSON Point features.", + "description": "A Place is a point representation of a real-world facility, service, or amenity.", "properties": { "bbox": { "description": "An optional bounding box for the feature", @@ -310,7 +313,7 @@ "type": "array" }, "geometry": { - "description": "Position of the place", + "description": "Position of the place. Places are point geometries.", "properties": { "bbox": { "items": { @@ -362,6 +365,7 @@ }, "properties": { "addresses": { + "description": "The address or addresses of the place", "items": { "$ref": "#/$defs/Address" }, @@ -370,19 +374,20 @@ "type": "array" }, "basic_category": { - "description": "The basic level category of a place. At present this is a mapping of the categories.primary entry to a new, simplified name. This mapping can be 1:1 or M:1 from the existing primary.categories entry. If the entry is currently empty in categories.primary, this entry will be empty. This type of categorization is a cognitive science model that is relevant for taxonomy and ontology development that shows the most broadest and general category name that is most often found in the middle of a general-to-specific hierarchy, with generalization proceeding upward and specialization proceeding downward. The full list of basic level categories is available at:(todo)", + "description": "The basic level category of a place.\n\nThis field classifies places into categories at a level that most people find\nintuitive. The full list of possible values it may hold can be found at (TODO).\n\nThe basic level category, or simply basic category, is based on a cognitive science\nmodel use in taxonomy and ontology development. The idea is to provide the category\nname at the level of generality that is preferred by humans in learning and memory\ntasks. This category to be roughly in the middle of the general-to-specific category\nhierarchy.", "pattern": "^[a-z0-9]+(_[a-z0-9]+)*$", "title": "Basic Category", "type": "string" }, "brand": { - "$ref": "#/$defs/Brand" + "$ref": "#/$defs/Brand", + "description": "The brand associated with the place." }, "categories": { "$ref": "#/$defs/Categories" }, "confidence": { - "description": "The confidence of the existence of the place. It's a number between 0 and 1. 0 means that we're sure that the place doesn't exist (anymore). 1 means that we're sure that the place exists. If there's no value for the confidence, it means that we don't have any confidence information. Places with operating_status set to 'closed' will have a confidence score of 0", + "description": "A score between 0 and 1 indicating how confident we are that the place exists.\n\nA confidence score of 0 indicates that we are certain the place doesn't exist\nanymore and will always be paired with an `operating_status` of\n`\"permanently_closed\"`.\n\nA confidence score of 1 indicates that we are certain the place does exist.\n\nIf there is no value for confidence, it means we don't have enough information on\nwhich to estimate our confidence level.", "maximum": 1.0, "minimum": 0.0, "title": "Confidence", @@ -404,7 +409,7 @@ }, "operating_status": { "$ref": "#/$defs/OperatingStatus", - "description": "Indicates the operating status of a place, can be one of [\"open\", \"permanently_closed\", \"temporarily_closed\"].\nThis is not an indication of 'opening hours' or that the place is open/closed at the current time-of-day or day-of-week." + "description": "An indication of whether a place is: in continued operation, in a temporary\noperating hiatus, or closed permanently.\n\nThis is not an indication of opening hours or that the place is open/closed at the\ncurrent time-of-day or day-of-week.\n\nWhen `operating_status` is `\"permanently_closed\"`, the `confidence` field will be\nset to 0." }, "phones": { "description": "The phone numbers of the place.", diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index f7b795f5b..3ebdb93e6 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -6,6 +6,8 @@ Subpackages ----------- +- :mod:`doc `. Documentation support for things that are hard to + document in Python, such as enumeration members. - :mod:`feature ` The `Feature` type, a Pydantic model type for geospatial data whose JSON Schema and serialized JSON representation are compatible with GeoJSON. - :mod:`field_constraint ` Constraints that can be @@ -140,6 +142,7 @@ """ from . import ( + doc, feature, field_constraint, metadata, @@ -153,6 +156,7 @@ __all__ = [ "create_model", + "doc", "feature", "field_constraint", "metadata", diff --git a/packages/overture-schema-system/src/overture/schema/system/doc.py b/packages/overture-schema-system/src/overture/schema/system/doc.py new file mode 100644 index 000000000..8f72ce08e --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/doc.py @@ -0,0 +1,82 @@ +from enum import Enum +from typing import TypeVar, cast + +T = TypeVar("T", bound="DocumentedEnum") + + +class DocumentedEnum(Enum): + """ + Base class for enumerations whose members have their own documentation strings. + + For historical reasons, the Python enumeration system does not recognize docstrings on + enumeration members. This limitation is usually not a problem, since in many use cases, + enumeration members are self-documenting, or they can be documented with Python comments, or + they can be documented at the level of the enumeration class. + + However, when authoring schemas in Pydantic, the inability to document enumeration members + in a Python-native manner becomes problematic. Sometimes enumerations may have many members, + the members may have subtleties that aren't obvious from the name, and it is desirable for + code generation tools to have access to metadata that they can use to document the code + generated for the enumeration members. + + Supporting this use case is the narrow purpose of this class. It should not be used to document + builder-facing enumerations whose primary audience is builders who are authoring schemas or + extending the schema system itself. Builder-facing enumerations can be documented with comments + or with pseudo-docstrings that the `pdoc` tool will understand and bring into the API reference, + even if Python doesn't understand them. + + Examples + -------- + + A documented enumeration: + + >>> class Status(str, DocumentedEnum): + ... PENDING = ("pending", "Request is awaiting review") + ... APPROVED = ("approved", "Request has been approved") + ... REJECTED = ("rejected", "Request has been rejected") + >>> Status.PENDING.__doc__ + 'Request is awaiting review' + + Documentation is optional on a per-member basis: + + >>> class ConnectionState(str, DocumentedEnum): + ... CONNECTED = "connected" + ... DISCONNECTED = "disconnected" + ... QUIESCING = ( + ... "quiescing", + ... "Gracefully shutting down, rejecting new requests but completing existing ones", + ... ) + >>> ConnectionState.CONNECTED.__doc__ is None + True + >>> ConnectionState.QUIESCING.__doc__ + 'Gracefully shutting down, rejecting new requests but completing existing ones' + + The previous examples showed the common case of multiple inheritance from `str`, but this is + not necessary. The enum can be another type such as `int`: + + >>> class Priority(int, DocumentedEnum): + ... LOW = 1 + ... MEDIUM = 5 + ... HIGH = (10, "High priority should only be used by system processes.") + + Or it can be of no particular type: + + >>> class HttpStatus(DocumentedEnum): + ... OK = (200, "The request succeeded") + ... NOT_FOUND = (404, "The server cannot find the requested resource") + ... INTERNAL_SERVER_ERROR = (500, "The server encountered an unexpected condition") + """ + + def __new__(cls: type, value: object, doc: str | None = None) -> "DocumentedEnum": + if len(cls.__bases__) == 2: + base_cls = next(base for base in cls.__bases__ if base != Enum) + obj = cast(DocumentedEnum, base_cls.__new__(cls, value)) + elif len(cls.__bases__) > 2: + raise TypeError( + f"too many base classes: only 1-2 are supported, but `{cls.__name__}` has {len(cls.__bases__)}: {repr(cls.__bases__)}" + ) + else: + obj = cast(DocumentedEnum, object.__new__(cls)) + obj._value_ = value + obj.__doc__ = doc + return cast(DocumentedEnum, obj) diff --git a/packages/overture-schema-system/src/overture/schema/system/primitive/__init__.py b/packages/overture-schema-system/src/overture/schema/system/primitive/__init__.py index 07d7f0197..96bfd0250 100644 --- a/packages/overture-schema-system/src/overture/schema/system/primitive/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/primitive/__init__.py @@ -95,14 +95,6 @@ them portable across different serialization and validation platforms. """ -pct = NewType("pct", Annotated[float, Field(ge=0, le=1)]) # type: ignore[type-arg] -""" -Portable percent value in the range [0, 1] where 0 represents 0% and 1 represents 100%. - -This is a `float` at runtime, but using `pct` for Pydantic model fields instead of `float` makes -them portable across different serialization and validation platforms. -""" - __all__ = [ "BBox", @@ -115,7 +107,6 @@ "int64", "float32", "float64", - "pct", "uint8", "uint16", "uint32", diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/enums.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/enums.py index 7875e2c6d..0a4fb0af1 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/enums.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/enums.py @@ -1,7 +1,6 @@ """Transportation theme enums.""" from enum import Enum -from typing import Annotated class Subtype(str, Enum): @@ -63,32 +62,6 @@ class RailClass(str, Enum): UNKNOWN = "unknown" -class Heading(str, Enum): - """Enumerates possible travel headings along segment geometry.""" - - FORWARD = "forward" - BACKWARD = "backward" - - -class TravelMode(str, Enum): - """Enumerates possible travel modes. - - Some modes represent groups of modes. - """ - - VEHICLE = "vehicle" - MOTOR_VEHICLE = "motor_vehicle" # includes car, truck and motorcycle - CAR = "car" - TRUCK = "truck" - MOTORCYCLE = "motorcycle" - FOOT = "foot" - BICYCLE = "bicycle" - BUS = "bus" - HGV = "hgv" - HOV = "hov" - EMERGENCY = "emergency" - - class DestinationSignSymbol(str, Enum): """Indicates what special symbol/icon is present on a signpost, visible as road marking or similar.""" @@ -170,95 +143,6 @@ class Subclass(str, Enum): CYCLE_CROSSING = "cycle_crossing" # Cycleway that intersects with other roads -class SpeedUnit(str, Enum): - """Speed unit.""" - - MPH = "mph" - KPH = "km/h" - - -class PurposeOfUse(str, Enum): - """Reason why a person or entity travelling on the transportation network is using a - particular location.""" - - AS_CUSTOMER = "as_customer" - AT_DESTINATION = "at_destination" - TO_DELIVER = "to_deliver" - TO_FARM = "to_farm" - FOR_FORESTRY = "for_forestry" - - -class RecognizedStatus(str, Enum): - """Status of the person or entity travelling as recognized by authorities - controlling the particular location.""" - - AS_PERMITTED = "as_permitted" - AS_PRIVATE = "as_private" - AS_DISABLED = "as_disabled" - AS_EMPLOYEE = "as_employee" - AS_STUDENT = "as_student" - - -class VehicleDimension(str, Enum): - """Enumerates possible vehicle dimensions for use in restrictions.""" - - AXLE_COUNT = "axle_count" - HEIGHT = "height" - LENGTH = "length" - WEIGHT = "weight" - WIDTH = "width" - - -class VehicleComparison(str, Enum): - """Enumerates possible comparison operators for use in scoping.""" - - GREATER_THAN = "greater_than" - GREATER_THAN_EQUAL = "greater_than_equal" - EQUAL = "equal" - LESS_THAN = "less_than" - LESS_THAN_EQUAL = "less_than_equal" - - -class LengthUnit(str, Enum): - """Enumerates length units supported by the Overture schema.""" - - # Keep in sync with `combobulib/measure.py`. - - # Imperial units. - IN = "in" # Imperial: Inch. - FT = "ft" # Imperial: Foot. - YD = "yd" # Imperial: Yard. - MI = "mi" # Imperial: Mile. - - # SI units. - CM = "cm" # SI: centimeter. - M = "m" # SI: meter. - KM = "km" # SI: kilometer. - - -class WeightUnit(str, Enum): - """Enumerates weight units supported by the Overture schema.""" - - # Keep in sync with `combobulib/measure.py`. - - # Imperial units. - OZ = "oz" # Imperial: Ounce. - LB = "lb" # Imperial: Pound. - ST = "st" # Imperial: Short Ton. - LT = "lt" # Imperial: Long Ton. - - # SI units. - G = "g" # SI: gram. - KG = "kg" # SI: kilogram. - T = "t" # SI: tonne. - - -VehicleScopeUnit = Annotated[LengthUnit | WeightUnit, None] -VehicleScopeUnit.__doc__ = ( - """Parent enum of both length and width for use in vehicle scoping""" -) - - class AccessType(str, Enum): ALLOWED = "allowed" DENIED = "denied" diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py index 1357e507f..be291ea93 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py @@ -5,15 +5,13 @@ from pydantic import BaseModel, ConfigDict, Field from overture.schema.core import OvertureFeature -from overture.schema.core.models import GeometricRangeScope +from overture.schema.core.scoping import Heading, Scope, scoped from overture.schema.core.types import ( Level, - LinearlyReferencedPosition, - OpeningHours, ) +from overture.schema.core.unit import SpeedUnit from overture.schema.system.field_constraint import UniqueItemsConstraint from overture.schema.system.model_constraint import ( - min_fields_set, no_extra_fields, require_any_of, ) @@ -25,20 +23,10 @@ AccessType, DestinationLabelType, DestinationSignSymbol, - Heading, - LengthUnit, - PurposeOfUse, RailFlag, - RecognizedStatus, RoadFlag, RoadSurface, - SpeedUnit, Subclass, - TravelMode, - VehicleComparison, - VehicleDimension, - VehicleScopeUnit, - WeightUnit, ) SpeedValue = NewType( @@ -55,6 +43,7 @@ def _connector_type() -> type[OvertureFeature]: @no_extra_fields +@scoped(Scope.GEOMETRIC_POSITION) class ConnectorReference(BaseModel): """Contains the GERS ID and relative position between 0 and 1 of a connector feature along the segment.""" @@ -64,23 +53,6 @@ class ConnectorReference(BaseModel): # Required connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, _connector_type())] - at: LinearlyReferencedPosition - - -@no_extra_fields -class HeadingScope(BaseModel): - """Properties defining travel headings that match a rule.""" - - model_config = ConfigDict(frozen=True) - - # Optional - - heading: Heading | None = None - - -@min_fields_set(1) -class DestinationWhenClause(HeadingScope): - pass @no_extra_fields @@ -100,6 +72,7 @@ class DestinationLabels(BaseModel): @require_any_of("labels", "symbols") @no_extra_fields +@scoped(Scope.HEADING) class DestinationRule(BaseModel): # Required @@ -145,10 +118,11 @@ class DestinationRule(BaseModel): ), UniqueItemsConstraint(), ] = None - when: DestinationWhenClause | None = None -class RouteReference(GeometricRangeScope): +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE) +class RouteReference(BaseModel): """Route reference with linear referencing support.""" # Optional @@ -187,137 +161,6 @@ class Speed(BaseModel): unit: SpeedUnit -@no_extra_fields -class IsMoreThanIntegerRelation(BaseModel): - is_more_than: int32 - - -@no_extra_fields -class IsAtLeastIntegerRelation(BaseModel): - is_at_least: int32 - - -@no_extra_fields -class IsEqualToIntegerRelation(BaseModel): - is_equal_to: int32 - - -@no_extra_fields -class IsAtMostIntegerRelation(BaseModel): - is_at_most: int32 - - -@no_extra_fields -class IsLessThanIntegerRelation(BaseModel): - is_less_than: int32 - - -IntegerRelation = Annotated[ - IsMoreThanIntegerRelation - | IsAtLeastIntegerRelation - | IsEqualToIntegerRelation - | IsAtMostIntegerRelation - | IsLessThanIntegerRelation, - None, -] -IntegerRelation.__doc__ = """Completes an integer relational expression of the form . An example of such an expression is: - `{ axle_count: { is_less_than: 2 } }`.""" - - -@no_extra_fields -class LengthValueWithUnit(BaseModel): - """Combines a length value with a length unit.""" - - # Required - - unit: LengthUnit - value: Annotated[float64, Field(ge=0)] - - -@no_extra_fields -class IsMoreThanLengthRelation(BaseModel): - is_more_than: LengthValueWithUnit - - -@no_extra_fields -class IsAtLeastLengthRelation(BaseModel): - is_at_least: LengthValueWithUnit - - -@no_extra_fields -class IsEqualToLengthRelation(BaseModel): - is_equal_to: LengthValueWithUnit - - -@no_extra_fields -class IsAtMostLengthRelation(BaseModel): - is_at_most: LengthValueWithUnit - - -@no_extra_fields -class IsLessThanLengthRelation(BaseModel): - is_less_than: LengthValueWithUnit - - -LengthRelation = Annotated[ - IsMoreThanLengthRelation - | IsAtLeastLengthRelation - | IsEqualToLengthRelation - | IsAtMostLengthRelation - | IsLessThanLengthRelation, - None, -] -LengthRelation.__doc__ = """Completes a length relational expression of the form . An example of such an expression is: - `{ height: { is_less_than: { value: 3, unit: 'm' } } }`.""" - - -@no_extra_fields -class WeightValueWithUnit(BaseModel): - """Combines a weight value with a weight unit.""" - - # Required - - unit: WeightUnit - value: Annotated[float64, Field(ge=0)] - - -@no_extra_fields -class IsMoreThanWeightRelation(BaseModel): - is_more_than: WeightValueWithUnit - - -@no_extra_fields -class IsAtLeastWeightRelation(BaseModel): - is_at_least: WeightValueWithUnit - - -@no_extra_fields -class IsEqualToWeightRelation(BaseModel): - is_equal_to: WeightValueWithUnit - - -@no_extra_fields -class IsAtMostWeightRelation(BaseModel): - is_at_most: WeightValueWithUnit - - -@no_extra_fields -class IsLessThanWeightRelation(BaseModel): - is_less_than: WeightValueWithUnit - - -WeightRelation = Annotated[ - IsMoreThanWeightRelation - | IsAtLeastWeightRelation - | IsEqualToWeightRelation - | IsAtMostWeightRelation - | IsLessThanWeightRelation, - None, -] -WeightRelation.__doc__ = """ Completes a weight relational expression of the form . An example of such an expression is: -`{ weight: { is_more_than: { value: 2, unit: 't' } } }`.""" - - @no_extra_fields class SequenceEntry(BaseModel): """A segment/connector pair in a prohibited transition sequence.""" @@ -341,127 +184,19 @@ class SequenceEntry(BaseModel): @no_extra_fields -class PurposeOfUseScope(BaseModel): - """Properties defining usage purposes that match a rule.""" - - model_config = ConfigDict(frozen=True) - - # Optional - - using: Annotated[ - list[PurposeOfUse] | None, Field(min_length=1), UniqueItemsConstraint() - ] = None - - def __hash__(self) -> int: - """Make PurposeOfUseScope hashable.""" - return hash((tuple(self.using) if self.using is not None else None,)) - - -@no_extra_fields -class TemporalScope(BaseModel): - """Temporal scoping properties defining the time spans when a recurring rule is - active.""" - - model_config = ConfigDict(frozen=True) - - # Optional - - during: OpeningHours | None = None - - -@no_extra_fields -class TravelModeScope(BaseModel): - """Properties defining travel modes that match a rule.""" - - model_config = ConfigDict(frozen=True) - - # Optional - - mode: Annotated[ - list[TravelMode] | None, - Field(min_length=1, description="Travel mode(s) to which the rule applies"), - UniqueItemsConstraint(), - ] = None - - def __hash__(self) -> int: - """Make TravelModeScope hashable.""" - return hash((tuple(self.mode) if self.mode is not None else None,)) - - -@no_extra_fields -class RecognizedStatusScope(BaseModel): - """Properties defining statuses that match a rule.""" - - model_config = ConfigDict(frozen=True) - - # Optional - - recognized: Annotated[ - list[RecognizedStatus] | None, Field(min_length=1), UniqueItemsConstraint() - ] = None - - def __hash__(self) -> int: - """Make RecognizedStatusScope hashable.""" - return hash((tuple(self.recognized) if self.recognized is not None else None,)) - - -@no_extra_fields -class VehicleScopeRule(BaseModel): - """An individual vehicle scope rule.""" - - model_config = ConfigDict(frozen=True) - - # Required - - dimension: VehicleDimension - comparison: VehicleComparison - value: Annotated[float64, Field(ge=0)] - - # Optional - - unit: VehicleScopeUnit | None = None - - -@no_extra_fields -class VehicleScope(BaseModel): - """Properties defining vehicle attributes for which a rule is active.""" - - model_config = ConfigDict(frozen=True) - - # Optional - - vehicle: Annotated[ - list[VehicleScopeRule] | None, - Field( - min_length=1, - description="Vehicle attributes for which the rule applies", - ), - UniqueItemsConstraint(), - ] = None - - def __hash__(self) -> int: - """Make VehicleScope hashable.""" - return hash((tuple(self.vehicle) if self.vehicle is not None else None,)) - - -@min_fields_set(1) -class SpeedLimitWhenClause( - TemporalScope, - HeadingScope, - PurposeOfUseScope, - RecognizedStatusScope, - TravelModeScope, - VehicleScope, -): - pass - - @require_any_of("max_speed", "min_speed") -class SpeedLimitRule(GeometricRangeScope): +@scoped( + Scope.GEOMETRIC_RANGE, + Scope.HEADING, + Scope.PURPOSE_OF_USE, + Scope.RECOGNIZED_STATUS, + Scope.TEMPORAL, + Scope.TRAVEL_MODE, + Scope.VEHICLE, +) +class SpeedLimitRule(BaseModel): """An individual speed limit rule.""" - # TODO: Speed limits probably have directionality, so should factor out a headingScopeContainer for this purpose and use it to introduce an optional direction property in each rule. - # Optional max_speed: Speed | None = None @@ -474,63 +209,37 @@ class SpeedLimitRule(GeometricRangeScope): strict=True, ), ] = False - when: SpeedLimitWhenClause | None = None - - -@min_fields_set(1) -class AccessRestrictionWhenClause( - TemporalScope, - HeadingScope, - PurposeOfUseScope, - RecognizedStatusScope, - TravelModeScope, - VehicleScope, -): - model_config = ConfigDict(frozen=True) - def __hash__(self) -> int: - """Make AccessRestrictionWhenClause hashable.""" - return hash( - ( - TemporalScope.__hash__(self), - HeadingScope.__hash__(self), - PurposeOfUseScope.__hash__(self), - RecognizedStatusScope.__hash__(self), - TravelModeScope.__hash__(self), - VehicleScope.__hash__(self), - ) - ) - - -class AccessRestrictionRule(GeometricRangeScope): + +@no_extra_fields +@scoped( + Scope.GEOMETRIC_RANGE, + Scope.HEADING, + Scope.PURPOSE_OF_USE, + Scope.RECOGNIZED_STATUS, + Scope.TEMPORAL, + Scope.TRAVEL_MODE, + Scope.VEHICLE, +) +class AccessRestrictionRule(BaseModel): model_config = ConfigDict(frozen=True) # Required access_type: AccessType - # Optional - - when: AccessRestrictionWhenClause | None = None - def __hash__(self) -> int: - """Make AccessRestrictionRule hashable.""" - return hash((super().__hash__(), self.access_type, self.when)) - - -@min_fields_set(1) -class ProhibitedTransitionWhenClause( - HeadingScope, - TemporalScope, - PurposeOfUseScope, - RecognizedStatusScope, - TravelModeScope, - VehicleScope, -): - pass - - -class ProhibitedTransitionRule(GeometricRangeScope): +@no_extra_fields +@scoped( + Scope.GEOMETRIC_RANGE, + Scope.HEADING, + Scope.PURPOSE_OF_USE, + Scope.RECOGNIZED_STATUS, + Scope.TEMPORAL, + Scope.TRAVEL_MODE, + Scope.VEHICLE, +) +class ProhibitedTransitionRule(BaseModel): # Required sequence: Annotated[ @@ -548,12 +257,10 @@ class ProhibitedTransitionRule(GeometricRangeScope): ), ] - # Optional - - when: ProhibitedTransitionWhenClause | None = None - -class RoadFlagRule(GeometricRangeScope): +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE) +class RoadFlagRule(BaseModel): """Road-specific flag rule with geometric scoping only.""" # Required @@ -561,7 +268,9 @@ class RoadFlagRule(GeometricRangeScope): values: Annotated[list[RoadFlag], Field(min_length=1), UniqueItemsConstraint()] -class RailFlagRule(GeometricRangeScope): +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE) +class RailFlagRule(BaseModel): """Rail-specific flag rule with geometric scoping only.""" # Required @@ -569,7 +278,9 @@ class RailFlagRule(GeometricRangeScope): values: Annotated[list[RailFlag], Field(min_length=1), UniqueItemsConstraint()] -class LevelRule(GeometricRangeScope): +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE) +class LevelRule(BaseModel): """A single level rule defining the Z-order, i.e. stacking order, applicable within a given scope on the road segment.""" @@ -578,7 +289,9 @@ class LevelRule(GeometricRangeScope): value: Level -class SubclassRule(GeometricRangeScope): +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE) +class SubclassRule(BaseModel): """Set of subclasses scoped along segment.""" # Required @@ -586,13 +299,17 @@ class SubclassRule(GeometricRangeScope): value: Subclass -class SurfaceRule(GeometricRangeScope): +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE) +class SurfaceRule(BaseModel): # Required value: RoadSurface -class WidthRule(GeometricRangeScope): +@no_extra_fields +@scoped(Scope.GEOMETRIC_RANGE) +class WidthRule(BaseModel): # Required value: Width diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py index 1c11fb216..d890b0d0a 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py @@ -7,7 +7,7 @@ from overture.schema.core import ( OvertureFeature, ) -from overture.schema.core.models import ( +from overture.schema.core.names import ( Named, ) from overture.schema.system.field_constraint import UniqueItemsConstraint diff --git a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json index 6ef8f5ca6..96d58fdb4 100644 --- a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json @@ -7,7 +7,7 @@ "$ref": "#/$defs/AccessType" }, "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing AccessRestrictionRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -19,7 +19,7 @@ "type": "array" }, "when": { - "$ref": "#/$defs/AccessRestrictionWhenClause" + "$ref": "#/$defs/overture__schema__transportation__models__AccessRestrictionRule__When" } }, "required": [ @@ -28,60 +28,6 @@ "title": "AccessRestrictionRule", "type": "object" }, - "AccessRestrictionWhenClause": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "during": { - "description": "Time span or time spans during which something is open or active, specified in the OSM opening hours specification: https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification", - "title": "During", - "type": "string" - }, - "heading": { - "$ref": "#/$defs/Heading" - }, - "mode": { - "description": "Travel mode(s) to which the rule applies", - "items": { - "$ref": "#/$defs/TravelMode" - }, - "minItems": 1, - "title": "Mode", - "type": "array", - "uniqueItems": true - }, - "recognized": { - "items": { - "$ref": "#/$defs/RecognizedStatus" - }, - "minItems": 1, - "title": "Recognized", - "type": "array", - "uniqueItems": true - }, - "using": { - "items": { - "$ref": "#/$defs/PurposeOfUse" - }, - "minItems": 1, - "title": "Using", - "type": "array", - "uniqueItems": true - }, - "vehicle": { - "description": "Vehicle attributes for which the rule applies", - "items": { - "$ref": "#/$defs/VehicleScopeRule" - }, - "minItems": 1, - "title": "Vehicle", - "type": "array", - "uniqueItems": true - } - }, - "title": "AccessRestrictionWhenClause", - "type": "object" - }, "AccessType": { "enum": [ "allowed", @@ -96,9 +42,9 @@ "description": "Contains the GERS ID and relative position between 0 and 1 of a connector feature\nalong the segment.", "properties": { "at": { - "description": "Represents a linearly-referenced position between 0% and 100% of the distance along a path such as a road segment or a river center-line segment.", - "maximum": 1, - "minimum": 0, + "description": "The linearly-referenced position on the geometry, specified as a percentage displacement from the start of the geometry, that the containing ConnectorReference applies to.", + "maximum": 1.0, + "minimum": 0.0, "title": "At", "type": "number" }, @@ -111,8 +57,7 @@ } }, "required": [ - "connector_id", - "at" + "connector_id" ], "title": "ConnectorReference", "type": "object" @@ -211,7 +156,7 @@ "type": "string" }, "when": { - "$ref": "#/$defs/DestinationWhenClause" + "$ref": "#/$defs/overture__schema__transportation__models__DestinationRule__When" } }, "required": [ @@ -250,19 +195,8 @@ "title": "DestinationSignSymbol", "type": "string" }, - "DestinationWhenClause": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "heading": { - "$ref": "#/$defs/Heading" - } - }, - "title": "DestinationWhenClause", - "type": "object" - }, "Heading": { - "description": "Enumerates possible travel headings along segment geometry.", + "description": "Travel direction along an oriented path: forward or backward.", "enum": [ "forward", "backward" @@ -271,7 +205,7 @@ "type": "string" }, "LengthUnit": { - "description": "Enumerates length units supported by the Overture schema.", + "description": "Unit of length.", "enum": [ "in", "ft", @@ -289,7 +223,7 @@ "description": "A single level rule defining the Z-order, i.e. stacking order, applicable within\na given scope on the road segment.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing LevelRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -317,7 +251,7 @@ "description": "Name rule with variant and language specification.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing NameRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -339,7 +273,8 @@ "description": "Political perspectives from which a named feature is viewed." }, "side": { - "$ref": "#/$defs/Side" + "$ref": "#/$defs/Side", + "description": "The side, either left or right, that the containing NameRule applies to." }, "value": { "description": "String with no leading/trailing whitespace", @@ -453,7 +388,7 @@ "additionalProperties": false, "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing ProhibitedTransitionRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -479,7 +414,7 @@ "uniqueItems": true }, "when": { - "$ref": "#/$defs/ProhibitedTransitionWhenClause" + "$ref": "#/$defs/overture__schema__transportation__models__ProhibitedTransitionRule__When" } }, "required": [ @@ -489,60 +424,6 @@ "title": "ProhibitedTransitionRule", "type": "object" }, - "ProhibitedTransitionWhenClause": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "during": { - "description": "Time span or time spans during which something is open or active, specified in the OSM opening hours specification: https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification", - "title": "During", - "type": "string" - }, - "heading": { - "$ref": "#/$defs/Heading" - }, - "mode": { - "description": "Travel mode(s) to which the rule applies", - "items": { - "$ref": "#/$defs/TravelMode" - }, - "minItems": 1, - "title": "Mode", - "type": "array", - "uniqueItems": true - }, - "recognized": { - "items": { - "$ref": "#/$defs/RecognizedStatus" - }, - "minItems": 1, - "title": "Recognized", - "type": "array", - "uniqueItems": true - }, - "using": { - "items": { - "$ref": "#/$defs/PurposeOfUse" - }, - "minItems": 1, - "title": "Using", - "type": "array", - "uniqueItems": true - }, - "vehicle": { - "description": "Vehicle attributes for which the rule applies", - "items": { - "$ref": "#/$defs/VehicleScopeRule" - }, - "minItems": 1, - "title": "Vehicle", - "type": "array", - "uniqueItems": true - } - }, - "title": "ProhibitedTransitionWhenClause", - "type": "object" - }, "PurposeOfUse": { "description": "Reason why a person or entity travelling on the transportation network is using a\nparticular location.", "enum": [ @@ -590,7 +471,7 @@ "description": "Rail-specific flag rule with geometric scoping only.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing RailFlagRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -859,7 +740,7 @@ "description": "Road-specific flag rule with geometric scoping only.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing RoadFlagRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -1141,7 +1022,7 @@ "description": "Route reference with linear referencing support.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing RouteReference applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -1217,7 +1098,7 @@ "type": "object" }, "Side": { - "description": "Represents the side on which something appears relative to a facing or heading\ndirection, e.g. the side of a road relative to the road orientation, or relative to\nthe direction of travel of a person or vehicle.", + "description": "The side, left or right, on which something appears relative to a facing or heading direction\n(*e.g.*, the side of a road relative to the road orientation), or relative to the direction of\ntravel of a person or vehicle.", "enum": [ "left", "right" @@ -1321,7 +1202,7 @@ "description": "An individual speed limit rule.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing SpeedLimitRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -1345,68 +1226,14 @@ "$ref": "#/$defs/Speed" }, "when": { - "$ref": "#/$defs/SpeedLimitWhenClause" + "$ref": "#/$defs/overture__schema__transportation__models__SpeedLimitRule__When" } }, "title": "SpeedLimitRule", "type": "object" }, - "SpeedLimitWhenClause": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "during": { - "description": "Time span or time spans during which something is open or active, specified in the OSM opening hours specification: https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification", - "title": "During", - "type": "string" - }, - "heading": { - "$ref": "#/$defs/Heading" - }, - "mode": { - "description": "Travel mode(s) to which the rule applies", - "items": { - "$ref": "#/$defs/TravelMode" - }, - "minItems": 1, - "title": "Mode", - "type": "array", - "uniqueItems": true - }, - "recognized": { - "items": { - "$ref": "#/$defs/RecognizedStatus" - }, - "minItems": 1, - "title": "Recognized", - "type": "array", - "uniqueItems": true - }, - "using": { - "items": { - "$ref": "#/$defs/PurposeOfUse" - }, - "minItems": 1, - "title": "Using", - "type": "array", - "uniqueItems": true - }, - "vehicle": { - "description": "Vehicle attributes for which the rule applies", - "items": { - "$ref": "#/$defs/VehicleScopeRule" - }, - "minItems": 1, - "title": "Vehicle", - "type": "array", - "uniqueItems": true - } - }, - "title": "SpeedLimitWhenClause", - "type": "object" - }, "SpeedUnit": { - "description": "Speed unit.", + "description": "Unit of speed.", "enum": [ "mph", "km/h" @@ -1433,7 +1260,7 @@ "description": "Set of subclasses scoped along segment.", "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing SubclassRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -1458,7 +1285,7 @@ "additionalProperties": false, "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing SurfaceRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -1497,8 +1324,100 @@ "title": "TravelMode", "type": "string" }, - "VehicleComparison": { - "description": "Enumerates possible comparison operators for use in scoping.", + "VehicleAxleCountSelector": { + "additionalProperties": false, + "description": "Selects vehicles based on the number of axles they have.", + "properties": { + "comparison": { + "$ref": "#/$defs/VehicleRelation" + }, + "dimension": { + "const": "axle_count", + "title": "Dimension", + "type": "string" + }, + "value": { + "description": "Number of axles on the vehicle", + "maximum": 255, + "minimum": 0, + "title": "Value", + "type": "integer" + } + }, + "required": [ + "dimension", + "comparison", + "value" + ], + "title": "VehicleAxleCountSelector", + "type": "object" + }, + "VehicleHeightSelector": { + "additionalProperties": false, + "description": "Selects vehicles based on their height.", + "properties": { + "comparison": { + "$ref": "#/$defs/VehicleRelation" + }, + "dimension": { + "const": "height", + "title": "Dimension", + "type": "string" + }, + "unit": { + "$ref": "#/$defs/LengthUnit", + "description": "Height unit in which `value` is expressed" + }, + "value": { + "decription": "Vehicle height selection threshold in the given `unit`", + "minimum": 0, + "title": "Value", + "type": "number" + } + }, + "required": [ + "dimension", + "comparison", + "value", + "unit" + ], + "title": "VehicleHeightSelector", + "type": "object" + }, + "VehicleLengthSelector": { + "additionalProperties": false, + "description": "Selects vehicles based on their length.", + "properties": { + "comparison": { + "$ref": "#/$defs/VehicleRelation" + }, + "dimension": { + "const": "length", + "title": "Dimension", + "type": "string" + }, + "unit": { + "$ref": "#/$defs/LengthUnit", + "description": "Length unit in which `value` is expressed" + }, + "value": { + "description": "Vehicle length selection threshold in the given `unit`", + "minimum": 0, + "title": "Value", + "type": "number" + } + }, + "required": [ + "dimension", + "comparison", + "value", + "unit" + ], + "title": "VehicleLengthSelector", + "type": "object" + }, + "VehicleRelation": { + "description": "Relational operator, such as less than or equal to.", "enum": [ "greater_than", "greater_than_equal", @@ -1506,43 +1425,59 @@ "less_than", "less_than_equal" ], - "title": "VehicleComparison", + "title": "VehicleRelation", "type": "string" }, - "VehicleDimension": { - "description": "Enumerates possible vehicle dimensions for use in restrictions.", - "enum": [ - "axle_count", - "height", - "length", - "weight", - "width" + "VehicleWeightSelector": { + "additionalProperties": false, + "description": "Selects vehicles based on their weight.", + "properties": { + "comparison": { + "$ref": "#/$defs/VehicleRelation" + }, + "dimension": { + "const": "weight", + "title": "Dimension", + "type": "string" + }, + "unit": { + "$ref": "#/$defs/WeightUnit", + "description": "Weight unit in which `value` is expressed" + }, + "value": { + "description": "Vehicle weight selection threshold in the given `unit`", + "minimum": 0, + "title": "Value", + "type": "number" + } + }, + "required": [ + "dimension", + "comparison", + "value", + "unit" ], - "title": "VehicleDimension", - "type": "string" + "title": "VehicleWeightSelector", + "type": "object" }, - "VehicleScopeRule": { + "VehicleWidthSelector": { "additionalProperties": false, - "description": "An individual vehicle scope rule.", + "description": "Selects vehicles based on their width.", "properties": { "comparison": { - "$ref": "#/$defs/VehicleComparison" + "$ref": "#/$defs/VehicleRelation" }, "dimension": { - "$ref": "#/$defs/VehicleDimension" + "const": "width", + "title": "Dimension", + "type": "string" }, "unit": { - "anyOf": [ - { - "$ref": "#/$defs/LengthUnit" - }, - { - "$ref": "#/$defs/WeightUnit" - } - ], - "title": "Unit" + "$ref": "#/$defs/LengthUnit", + "description": "Width unit in which `value` is expressed" }, "value": { + "description": "Vehicle width selection threshold in the given `unit`", "minimum": 0, "title": "Value", "type": "number" @@ -1551,9 +1486,10 @@ "required": [ "dimension", "comparison", - "value" + "value", + "unit" ], - "title": "VehicleScopeRule", + "title": "VehicleWidthSelector", "type": "object" }, "WaterSegment": { @@ -1730,7 +1666,7 @@ "type": "object" }, "WeightUnit": { - "description": "Enumerates weight units supported by the Overture schema.", + "description": "Unit of weight.", "enum": [ "oz", "lb", @@ -1747,7 +1683,7 @@ "additionalProperties": false, "properties": { "between": { - "description": "Linear reference range [start, end] where 0.0 <= start < end <= 1.0", + "description": "The linearly-referenced sub-segment of the geometry, specified as a range (pair) of percentage displacements from the start of the geometry, that the containing WidthRule applies to.", "items": { "maximum": 1.0, "minimum": 0.0, @@ -1769,6 +1705,339 @@ ], "title": "WidthRule", "type": "object" + }, + "overture__schema__transportation__models__AccessRestrictionRule__When": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "heading" + ] + }, + { + "required": [ + "during" + ] + }, + { + "required": [ + "mode" + ] + }, + { + "required": [ + "using" + ] + }, + { + "required": [ + "recognized" + ] + }, + { + "required": [ + "vehicle" + ] + } + ], + "description": "Scopes for AccessRestrictionRule: Scope.HEADING, Scope.TEMPORAL, Scope.TRAVEL MODE, Scope.PURPOSE OF USE, Scope.RECOGNIZED STATUS and Scope.VEHICLE", + "properties": { + "during": { + "description": "The recurring time span, in the OpenStreetMap opening hours format, that the containing AccessRestrictionRule applies to. For the OSM opening hours specification, see https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification.", + "title": "During", + "type": "string" + }, + "heading": { + "$ref": "#/$defs/Heading", + "description": "The heading, either forward or backward, that the containing AccessRestrictionRule applies to." + }, + "mode": { + "description": "A list of one or more travel modes, such as car, truck, or foot, that the containing AccessRestrictionRule applies to.", + "items": { + "$ref": "#/$defs/TravelMode" + }, + "minItems": 1, + "title": "Mode", + "type": "array", + "uniqueItems": true + }, + "recognized": { + "description": "A list of one or more recognized status values, such as employee or student, that the containing AccessRestrictionRule applies to.", + "items": { + "$ref": "#/$defs/RecognizedStatus" + }, + "minItems": 1, + "title": "Recognized", + "type": "array", + "uniqueItems": true + }, + "using": { + "description": "A list of one or more usage purposes, such as delivery or arrival at final destination, that the containing AccessRestrictionRule applies to.", + "items": { + "$ref": "#/$defs/PurposeOfUse" + }, + "minItems": 1, + "title": "Using", + "type": "array", + "uniqueItems": true + }, + "vehicle": { + "description": "A list of one or more vehicle parameters that limit the vehicles the containing AccessRestrictionRule applies to.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/VehicleAxleCountSelector" + }, + { + "$ref": "#/$defs/VehicleHeightSelector" + }, + { + "$ref": "#/$defs/VehicleLengthSelector" + }, + { + "$ref": "#/$defs/VehicleWeightSelector" + }, + { + "$ref": "#/$defs/VehicleWidthSelector" + } + ], + "description": "Selects vehicles that a scope applies to based on criteria such as height, weight, or axle count." + }, + "minItems": 1, + "title": "Vehicle", + "type": "array", + "uniqueItems": true + } + }, + "title": "AccessRestrictionRule.When", + "type": "object" + }, + "overture__schema__transportation__models__DestinationRule__When": { + "additionalProperties": false, + "description": "Scope for DestinationRule: ", + "properties": { + "heading": { + "$ref": "#/$defs/Heading", + "description": "The heading, either forward or backward, that the containing DestinationRule applies to." + } + }, + "required": [ + "heading" + ], + "title": "DestinationRule.When", + "type": "object" + }, + "overture__schema__transportation__models__ProhibitedTransitionRule__When": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "heading" + ] + }, + { + "required": [ + "during" + ] + }, + { + "required": [ + "mode" + ] + }, + { + "required": [ + "using" + ] + }, + { + "required": [ + "recognized" + ] + }, + { + "required": [ + "vehicle" + ] + } + ], + "description": "Scopes for ProhibitedTransitionRule: Scope.HEADING, Scope.TEMPORAL, Scope.TRAVEL MODE, Scope.PURPOSE OF USE, Scope.RECOGNIZED STATUS and Scope.VEHICLE", + "properties": { + "during": { + "description": "The recurring time span, in the OpenStreetMap opening hours format, that the containing ProhibitedTransitionRule applies to. For the OSM opening hours specification, see https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification.", + "title": "During", + "type": "string" + }, + "heading": { + "$ref": "#/$defs/Heading", + "description": "The heading, either forward or backward, that the containing ProhibitedTransitionRule applies to." + }, + "mode": { + "description": "A list of one or more travel modes, such as car, truck, or foot, that the containing ProhibitedTransitionRule applies to.", + "items": { + "$ref": "#/$defs/TravelMode" + }, + "minItems": 1, + "title": "Mode", + "type": "array", + "uniqueItems": true + }, + "recognized": { + "description": "A list of one or more recognized status values, such as employee or student, that the containing ProhibitedTransitionRule applies to.", + "items": { + "$ref": "#/$defs/RecognizedStatus" + }, + "minItems": 1, + "title": "Recognized", + "type": "array", + "uniqueItems": true + }, + "using": { + "description": "A list of one or more usage purposes, such as delivery or arrival at final destination, that the containing ProhibitedTransitionRule applies to.", + "items": { + "$ref": "#/$defs/PurposeOfUse" + }, + "minItems": 1, + "title": "Using", + "type": "array", + "uniqueItems": true + }, + "vehicle": { + "description": "A list of one or more vehicle parameters that limit the vehicles the containing ProhibitedTransitionRule applies to.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/VehicleAxleCountSelector" + }, + { + "$ref": "#/$defs/VehicleHeightSelector" + }, + { + "$ref": "#/$defs/VehicleLengthSelector" + }, + { + "$ref": "#/$defs/VehicleWeightSelector" + }, + { + "$ref": "#/$defs/VehicleWidthSelector" + } + ], + "description": "Selects vehicles that a scope applies to based on criteria such as height, weight, or axle count." + }, + "minItems": 1, + "title": "Vehicle", + "type": "array", + "uniqueItems": true + } + }, + "title": "ProhibitedTransitionRule.When", + "type": "object" + }, + "overture__schema__transportation__models__SpeedLimitRule__When": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "heading" + ] + }, + { + "required": [ + "during" + ] + }, + { + "required": [ + "mode" + ] + }, + { + "required": [ + "using" + ] + }, + { + "required": [ + "recognized" + ] + }, + { + "required": [ + "vehicle" + ] + } + ], + "description": "Scopes for SpeedLimitRule: Scope.HEADING, Scope.TEMPORAL, Scope.TRAVEL MODE, Scope.PURPOSE OF USE, Scope.RECOGNIZED STATUS and Scope.VEHICLE", + "properties": { + "during": { + "description": "The recurring time span, in the OpenStreetMap opening hours format, that the containing SpeedLimitRule applies to. For the OSM opening hours specification, see https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification.", + "title": "During", + "type": "string" + }, + "heading": { + "$ref": "#/$defs/Heading", + "description": "The heading, either forward or backward, that the containing SpeedLimitRule applies to." + }, + "mode": { + "description": "A list of one or more travel modes, such as car, truck, or foot, that the containing SpeedLimitRule applies to.", + "items": { + "$ref": "#/$defs/TravelMode" + }, + "minItems": 1, + "title": "Mode", + "type": "array", + "uniqueItems": true + }, + "recognized": { + "description": "A list of one or more recognized status values, such as employee or student, that the containing SpeedLimitRule applies to.", + "items": { + "$ref": "#/$defs/RecognizedStatus" + }, + "minItems": 1, + "title": "Recognized", + "type": "array", + "uniqueItems": true + }, + "using": { + "description": "A list of one or more usage purposes, such as delivery or arrival at final destination, that the containing SpeedLimitRule applies to.", + "items": { + "$ref": "#/$defs/PurposeOfUse" + }, + "minItems": 1, + "title": "Using", + "type": "array", + "uniqueItems": true + }, + "vehicle": { + "description": "A list of one or more vehicle parameters that limit the vehicles the containing SpeedLimitRule applies to.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/VehicleAxleCountSelector" + }, + { + "$ref": "#/$defs/VehicleHeightSelector" + }, + { + "$ref": "#/$defs/VehicleLengthSelector" + }, + { + "$ref": "#/$defs/VehicleWeightSelector" + }, + { + "$ref": "#/$defs/VehicleWidthSelector" + } + ], + "description": "Selects vehicles that a scope applies to based on criteria such as height, weight, or axle count." + }, + "minItems": 1, + "title": "Vehicle", + "type": "array", + "uniqueItems": true + } + }, + "title": "SpeedLimitRule.When", + "type": "object" } }, "discriminator": { diff --git a/packages/overture-schema-transportation-theme/tests/test_hashability.py b/packages/overture-schema-transportation-theme/tests/test_hashability.py deleted file mode 100644 index 6b90b46f3..000000000 --- a/packages/overture-schema-transportation-theme/tests/test_hashability.py +++ /dev/null @@ -1,618 +0,0 @@ -"""Tests for hashability of all scope and related classes.""" - -from overture.schema.core.models import GeometricRangeScope -from overture.schema.system.primitive import Geometry -from overture.schema.transportation.enums import ( - AccessType, - DestinationLabelType, - Heading, - PurposeOfUse, - RecognizedStatus, - TravelMode, - VehicleComparison, - VehicleDimension, -) -from overture.schema.transportation.models import ( - AccessRestrictionRule, - AccessRestrictionWhenClause, - ConnectorReference, - DestinationLabels, - HeadingScope, - PurposeOfUseScope, - RecognizedStatusScope, - SequenceEntry, - Speed, - TemporalScope, - TravelModeScope, - VehicleScope, - VehicleScopeRule, -) -from shapely.geometry import Point - - -class TestGeometricRangeScopeHashability: - """Test hashability of GeometricRangeScope.""" - - def test_geometric_range_scope_with_none_between(self) -> None: - """Test GeometricRangeScope with None between field.""" - scope1 = GeometricRangeScope() - scope2 = GeometricRangeScope() - - assert hash(scope1) == hash(scope2) - assert scope1 == scope2 - - def test_geometric_range_scope_with_between_values(self) -> None: - """Test GeometricRangeScope with between field values.""" - scope1 = GeometricRangeScope(between=[0.0, 0.5]) - scope2 = GeometricRangeScope(between=[0.0, 0.5]) - scope3 = GeometricRangeScope(between=[0.5, 1.0]) - - assert hash(scope1) == hash(scope2) - assert hash(scope1) != hash(scope3) - assert scope1 == scope2 - assert scope1 != scope3 - - def test_geometric_range_scope_in_set(self) -> None: - """Test that GeometricRangeScope can be used in sets.""" - scope1 = GeometricRangeScope(between=[0.0, 0.5]) - scope2 = GeometricRangeScope(between=[0.0, 0.5]) - scope3 = GeometricRangeScope(between=[0.5, 1.0]) - - scope_set = {scope1, scope2, scope3} - assert len(scope_set) == 2 # scope1 and scope2 are equal - - -class TestTemporalScopeHashability: - """Test hashability of TemporalScope.""" - - def test_temporal_scope_with_none_during(self) -> None: - """Test TemporalScope with None during field.""" - scope1 = TemporalScope() - scope2 = TemporalScope() - - assert hash(scope1) == hash(scope2) - assert scope1 == scope2 - - def test_temporal_scope_with_during_values(self) -> None: - """Test TemporalScope with during field values.""" - scope1 = TemporalScope(during="Mo-Fr 08:00-17:00") - scope2 = TemporalScope(during="Mo-Fr 08:00-17:00") - scope3 = TemporalScope(during="Sa-Su") - - assert hash(scope1) == hash(scope2) - assert hash(scope1) != hash(scope3) - assert scope1 == scope2 - assert scope1 != scope3 - - def test_temporal_scope_in_set(self) -> None: - """Test that TemporalScope can be used in sets.""" - scope1 = TemporalScope(during="Mo-Fr 08:00-17:00") - scope2 = TemporalScope(during="Mo-Fr 08:00-17:00") - scope3 = TemporalScope(during="Sa-Su") - - scope_set = {scope1, scope2, scope3} - assert len(scope_set) == 2 - - -class TestHeadingScopeHashability: - """Test hashability of HeadingScope.""" - - def test_heading_scope_with_none_heading(self) -> None: - """Test HeadingScope with None heading field.""" - scope1 = HeadingScope() - scope2 = HeadingScope() - - assert hash(scope1) == hash(scope2) - assert scope1 == scope2 - - def test_heading_scope_with_heading_values(self) -> None: - """Test HeadingScope with heading field values.""" - scope1 = HeadingScope(heading="forward") - scope2 = HeadingScope(heading="forward") - scope3 = HeadingScope(heading="backward") - - assert hash(scope1) == hash(scope2) - assert hash(scope1) != hash(scope3) - assert scope1 == scope2 - assert scope1 != scope3 - - def test_heading_scope_in_set(self) -> None: - """Test that HeadingScope can be used in sets.""" - scope1 = HeadingScope(heading="forward") - scope2 = HeadingScope(heading="forward") - scope3 = HeadingScope(heading="backward") - - scope_set = {scope1, scope2, scope3} - assert len(scope_set) == 2 - - -class TestTravelModeScopeHashability: - """Test hashability of TravelModeScope.""" - - def test_travel_mode_scope_with_none_mode(self) -> None: - """Test TravelModeScope with None mode field.""" - scope1 = TravelModeScope() - scope2 = TravelModeScope() - - assert hash(scope1) == hash(scope2) - assert scope1 == scope2 - - def test_travel_mode_scope_with_mode_values(self) -> None: - """Test TravelModeScope with mode field values.""" - scope1 = TravelModeScope(mode=[TravelMode.CAR]) - scope2 = TravelModeScope(mode=[TravelMode.CAR]) - scope3 = TravelModeScope(mode=[TravelMode.FOOT, TravelMode.BICYCLE]) - - assert hash(scope1) == hash(scope2) - assert hash(scope1) != hash(scope3) - assert scope1 == scope2 - assert scope1 != scope3 - - def test_travel_mode_scope_in_set(self) -> None: - """Test that TravelModeScope can be used in sets.""" - scope1 = TravelModeScope(mode=[TravelMode.CAR]) - scope2 = TravelModeScope(mode=[TravelMode.CAR]) - scope3 = TravelModeScope(mode=[TravelMode.FOOT]) - - scope_set = {scope1, scope2, scope3} - assert len(scope_set) == 2 - - -class TestPurposeOfUseScopeHashability: - """Test hashability of PurposeOfUseScope.""" - - def test_purpose_of_use_scope_with_none_using(self) -> None: - """Test PurposeOfUseScope with None using field.""" - scope1 = PurposeOfUseScope() - scope2 = PurposeOfUseScope() - - assert hash(scope1) == hash(scope2) - assert scope1 == scope2 - - def test_purpose_of_use_scope_with_using_values(self) -> None: - """Test PurposeOfUseScope with using field values.""" - scope1 = PurposeOfUseScope(using=[PurposeOfUse.TO_DELIVER]) - scope2 = PurposeOfUseScope(using=[PurposeOfUse.TO_DELIVER]) - scope3 = PurposeOfUseScope(using=[PurposeOfUse.AT_DESTINATION]) - - assert hash(scope1) == hash(scope2) - assert hash(scope1) != hash(scope3) - assert scope1 == scope2 - assert scope1 != scope3 - - def test_purpose_of_use_scope_in_set(self) -> None: - """Test that PurposeOfUseScope can be used in sets.""" - scope1 = PurposeOfUseScope(using=[PurposeOfUse.TO_DELIVER]) - scope2 = PurposeOfUseScope(using=[PurposeOfUse.TO_DELIVER]) - scope3 = PurposeOfUseScope(using=[PurposeOfUse.AT_DESTINATION]) - - scope_set = {scope1, scope2, scope3} - assert len(scope_set) == 2 - - -class TestRecognizedStatusScopeHashability: - """Test hashability of RecognizedStatusScope.""" - - def test_recognized_status_scope_with_none_recognized(self) -> None: - """Test RecognizedStatusScope with None recognized field.""" - scope1 = RecognizedStatusScope() - scope2 = RecognizedStatusScope() - - assert hash(scope1) == hash(scope2) - assert scope1 == scope2 - - def test_recognized_status_scope_with_recognized_values(self) -> None: - """Test RecognizedStatusScope with recognized field values.""" - scope1 = RecognizedStatusScope(recognized=[RecognizedStatus.AS_EMPLOYEE]) - scope2 = RecognizedStatusScope(recognized=[RecognizedStatus.AS_EMPLOYEE]) - scope3 = RecognizedStatusScope(recognized=[RecognizedStatus.AS_PRIVATE]) - - assert hash(scope1) == hash(scope2) - assert hash(scope1) != hash(scope3) - assert scope1 == scope2 - assert scope1 != scope3 - - def test_recognized_status_scope_in_set(self) -> None: - """Test that RecognizedStatusScope can be used in sets.""" - scope1 = RecognizedStatusScope(recognized=[RecognizedStatus.AS_EMPLOYEE]) - scope2 = RecognizedStatusScope(recognized=[RecognizedStatus.AS_EMPLOYEE]) - scope3 = RecognizedStatusScope(recognized=[RecognizedStatus.AS_PRIVATE]) - - scope_set = {scope1, scope2, scope3} - assert len(scope_set) == 2 - - -class TestSpeedHashability: - """Test hashability of Speed.""" - - def test_speed_hashability(self) -> None: - """Test Speed hashability.""" - speed1 = Speed(value=50.0, unit="km/h") - speed2 = Speed(value=50.0, unit="km/h") - speed3 = Speed(value=30.0, unit="mph") - - assert hash(speed1) == hash(speed2) - assert hash(speed1) != hash(speed3) - assert speed1 == speed2 - assert speed1 != speed3 - - def test_speed_in_set(self) -> None: - """Test that Speed can be used in sets.""" - speed1 = Speed(value=50.0, unit="km/h") - speed2 = Speed(value=50.0, unit="km/h") - speed3 = Speed(value=30.0, unit="mph") - - speed_set = {speed1, speed2, speed3} - assert len(speed_set) == 2 - - -class TestVehicleConstraintHashability: - """Test hashability of VehicleConstraint.""" - - def test_vehicle_constraint_hashability(self) -> None: - """Test VehicleConstraint hashability.""" - constraint1 = VehicleScopeRule( - dimension=VehicleDimension.WEIGHT, - comparison=VehicleComparison.GREATER_THAN, - value=7.5, - unit="t", - ) - constraint2 = VehicleScopeRule( - dimension=VehicleDimension.WEIGHT, - comparison=VehicleComparison.GREATER_THAN, - value=7.5, - unit="t", - ) - constraint3 = VehicleScopeRule( - dimension=VehicleDimension.HEIGHT, - comparison=VehicleComparison.LESS_THAN, - value=3.8, - ) - - assert hash(constraint1) == hash(constraint2) - assert hash(constraint1) != hash(constraint3) - assert constraint1 == constraint2 - assert constraint1 != constraint3 - - def test_vehicle_constraint_in_set(self) -> None: - """Test that VehicleConstraint can be used in sets.""" - constraint1 = VehicleScopeRule( - dimension=VehicleDimension.WEIGHT, - comparison=VehicleComparison.GREATER_THAN, - value=7.5, - unit="t", - ) - constraint2 = VehicleScopeRule( - dimension=VehicleDimension.WEIGHT, - comparison=VehicleComparison.GREATER_THAN, - value=7.5, - unit="t", - ) - constraint3 = VehicleScopeRule( - dimension=VehicleDimension.HEIGHT, - comparison=VehicleComparison.LESS_THAN, - value=3.8, - ) - - constraint_set = {constraint1, constraint2, constraint3} - assert len(constraint_set) == 2 - - -class TestVehicleScopeHashability: - """Test hashability of VehicleScope.""" - - def test_vehicle_scope_with_none_vehicle(self) -> None: - """Test VehicleScope with None vehicle field.""" - scope1 = VehicleScope() - scope2 = VehicleScope() - - assert hash(scope1) == hash(scope2) - assert scope1 == scope2 - - def test_vehicle_scope_with_vehicle_values(self) -> None: - """Test VehicleScope with vehicle field values.""" - constraint = VehicleScopeRule( - dimension=VehicleDimension.WEIGHT, - comparison=VehicleComparison.GREATER_THAN, - value=7.5, - unit="t", - ) - - scope1 = VehicleScope(vehicle=[constraint]) - scope2 = VehicleScope(vehicle=[constraint]) - scope3 = VehicleScope() - - assert hash(scope1) == hash(scope2) - assert hash(scope1) != hash(scope3) - assert scope1 == scope2 - assert scope1 != scope3 - - def test_vehicle_scope_in_set(self) -> None: - """Test that VehicleScope can be used in sets.""" - constraint1 = VehicleScopeRule( - dimension=VehicleDimension.WEIGHT, - comparison=VehicleComparison.GREATER_THAN, - value=7.5, - unit="t", - ) - constraint2 = VehicleScopeRule( - dimension=VehicleDimension.HEIGHT, - comparison=VehicleComparison.LESS_THAN, - value=3.8, - ) - - scope1 = VehicleScope(vehicle=[constraint1]) - scope2 = VehicleScope(vehicle=[constraint1]) - scope3 = VehicleScope(vehicle=[constraint2]) - - scope_set = {scope1, scope2, scope3} - assert len(scope_set) == 2 - - -class TestAccessRestrictionWhenClauseHashability: - """Test hashability of AccessRestrictionWhenClause.""" - - def test_access_restriction_when_clause_empty(self) -> None: - """Test AccessRestrictionWhenClause with minimal fields set.""" - # At least one property must be set due to @min_fields_set(1) - clause1 = AccessRestrictionWhenClause(heading=Heading.FORWARD) - clause2 = AccessRestrictionWhenClause(heading=Heading.FORWARD) - - assert hash(clause1) == hash(clause2) - assert clause1 == clause2 - - def test_access_restriction_when_clause_with_values(self) -> None: - """Test AccessRestrictionWhenClause with various field values.""" - - clause1 = AccessRestrictionWhenClause( - during="Mo-Fr 08:00-17:00", - heading=Heading.FORWARD, - mode=[TravelMode.CAR], - using=[PurposeOfUse.TO_DELIVER], - ) - clause2 = AccessRestrictionWhenClause( - during="Mo-Fr 08:00-17:00", - heading=Heading.FORWARD, - mode=[TravelMode.CAR], - using=[PurposeOfUse.TO_DELIVER], - ) - clause3 = AccessRestrictionWhenClause(during="Sa-Su", heading=Heading.BACKWARD) - - assert hash(clause1) == hash(clause2) - assert hash(clause1) != hash(clause3) - assert clause1 == clause2 - assert clause1 != clause3 - - def test_access_restriction_when_clause_in_set(self) -> None: - """Test that AccessRestrictionWhenClause can be used in sets.""" - clause1 = AccessRestrictionWhenClause(during="Mo-Fr 08:00-17:00") - clause2 = AccessRestrictionWhenClause(during="Mo-Fr 08:00-17:00") - clause3 = AccessRestrictionWhenClause(during="Sa-Su") - - clause_set = {clause1, clause2, clause3} - assert len(clause_set) == 2 - - -class TestAccessRestrictionRuleHashability: - """Test hashability of AccessRestrictionRule.""" - - def test_access_restriction_rule_basic(self) -> None: - """Test AccessRestrictionRule basic hashability.""" - rule1 = AccessRestrictionRule(access_type=AccessType.DENIED) - rule2 = AccessRestrictionRule(access_type=AccessType.DENIED) - rule3 = AccessRestrictionRule(access_type=AccessType.ALLOWED) - - assert hash(rule1) == hash(rule2) - assert hash(rule1) != hash(rule3) - assert rule1 == rule2 - assert rule1 != rule3 - - def test_access_restriction_rule_with_when_clause(self) -> None: - """Test AccessRestrictionRule with when clause.""" - when_clause = AccessRestrictionWhenClause( - during="Mo-Fr 08:00-17:00", heading=Heading.FORWARD - ) - - rule1 = AccessRestrictionRule( - access_type=AccessType.DENIED, when=when_clause, between=[0.0, 0.5] - ) - rule2 = AccessRestrictionRule( - access_type=AccessType.DENIED, when=when_clause, between=[0.0, 0.5] - ) - rule3 = AccessRestrictionRule(access_type=AccessType.DENIED, between=[0.5, 1.0]) - - assert hash(rule1) == hash(rule2) - assert hash(rule1) != hash(rule3) - assert rule1 == rule2 - assert rule1 != rule3 - - def test_access_restriction_rule_in_set(self) -> None: - """Test that AccessRestrictionRule can be used in sets.""" - when_clause = AccessRestrictionWhenClause(during="Mo-Fr 08:00-17:00") - - rule1 = AccessRestrictionRule(access_type=AccessType.DENIED, when=when_clause) - rule2 = AccessRestrictionRule(access_type=AccessType.DENIED, when=when_clause) - rule3 = AccessRestrictionRule(access_type=AccessType.ALLOWED) - - rule_set = {rule1, rule2, rule3} - assert len(rule_set) == 2 - - def test_complex_access_restriction_rule_hashability(self) -> None: - """Test complex AccessRestrictionRule with all fields.""" - constraint = VehicleScopeRule( - dimension=VehicleDimension.WEIGHT, - comparison=VehicleComparison.GREATER_THAN, - value=7.5, - unit="t", - ) - - when_clause = AccessRestrictionWhenClause( - during="Mo-Fr 08:00-17:00", - heading=Heading.FORWARD, - mode=[TravelMode.CAR, TravelMode.TRUCK], - using=[PurposeOfUse.TO_DELIVER], - recognized=[RecognizedStatus.AS_EMPLOYEE], - vehicle=[constraint], - ) - - rule1 = AccessRestrictionRule( - access_type=AccessType.DENIED, when=when_clause, between=[0.0, 0.5] - ) - rule2 = AccessRestrictionRule( - access_type=AccessType.DENIED, when=when_clause, between=[0.0, 0.5] - ) - - assert hash(rule1) == hash(rule2) - assert rule1 == rule2 - - # Test in set - rule_set = {rule1, rule2} - assert len(rule_set) == 1 - - -class TestGeometryHashability: - """Test hashability of Geometry.""" - - def test_geometry_hashability(self) -> None: - """Test Geometry hashability.""" - # Note: Using Point geometry for testing - geom1 = Geometry(Point(1.0, 2.0)) - geom2 = Geometry(Point(1.0, 2.0)) - geom3 = Geometry(Point(3.0, 4.0)) - - assert hash(geom1) == hash(geom2) - assert hash(geom1) != hash(geom3) - assert geom1 == geom2 - assert geom1 != geom3 - - def test_geometry_in_set(self) -> None: - """Test that Geometry can be used in sets.""" - - geom1 = Geometry(Point(1.0, 2.0)) - geom2 = Geometry(Point(1.0, 2.0)) - geom3 = Geometry(Point(3.0, 4.0)) - - geom_set = {geom1, geom2, geom3} - assert len(geom_set) == 2 - - -class TestConnectorReferenceHashability: - """Test hashability of ConnectorReference.""" - - def test_connector_reference_hashability(self) -> None: - """Test ConnectorReference hashability.""" - ref1 = ConnectorReference(connector_id="conn_01", at=0.5) - ref2 = ConnectorReference(connector_id="conn_01", at=0.5) - ref3 = ConnectorReference(connector_id="conn_02", at=0.3) - - assert hash(ref1) == hash(ref2) - assert hash(ref1) != hash(ref3) - assert ref1 == ref2 - assert ref1 != ref3 - - def test_connector_reference_in_set(self) -> None: - """Test that ConnectorReference can be used in sets.""" - ref1 = ConnectorReference(connector_id="conn_01", at=0.5) - ref2 = ConnectorReference(connector_id="conn_01", at=0.5) - ref3 = ConnectorReference(connector_id="conn_02", at=0.3) - - ref_set = {ref1, ref2, ref3} - assert len(ref_set) == 2 - - -class TestDestinationLabelsHashability: - """Test hashability of DestinationLabels.""" - - def test_destination_labels_hashability(self) -> None: - """Test DestinationLabels hashability.""" - label1 = DestinationLabels( - value="Main Street", type=DestinationLabelType.STREET - ) - label2 = DestinationLabels( - value="Main Street", type=DestinationLabelType.STREET - ) - label3 = DestinationLabels( - value="Highway 101", type=DestinationLabelType.ROUTE_REF - ) - - assert hash(label1) == hash(label2) - assert hash(label1) != hash(label3) - assert label1 == label2 - assert label1 != label3 - - def test_destination_labels_with_different_values(self) -> None: - """Test DestinationLabels with different values but same type.""" - label1 = DestinationLabels( - value="Main Street", type=DestinationLabelType.STREET - ) - label2 = DestinationLabels(value="Oak Avenue", type=DestinationLabelType.STREET) - - assert hash(label1) != hash(label2) - assert label1 != label2 - - def test_destination_labels_with_different_types(self) -> None: - """Test DestinationLabels with same value but different types.""" - label1 = DestinationLabels( - value="Route 66", type=DestinationLabelType.ROUTE_REF - ) - label2 = DestinationLabels( - value="Route 66", type=DestinationLabelType.TOWARD_ROUTE_REF - ) - - assert hash(label1) != hash(label2) - assert label1 != label2 - - def test_destination_labels_in_set(self) -> None: - """Test that DestinationLabels can be used in sets.""" - label1 = DestinationLabels( - value="Main Street", type=DestinationLabelType.STREET - ) - label2 = DestinationLabels( - value="Main Street", type=DestinationLabelType.STREET - ) - label3 = DestinationLabels( - value="Highway 101", type=DestinationLabelType.ROUTE_REF - ) - - label_set = {label1, label2, label3} - assert len(label_set) == 2 - - -class TestSequenceEntryHashability: - """Test hashability of SequenceEntry.""" - - def test_sequence_entry_hashability(self) -> None: - """Test SequenceEntry hashability.""" - entry1 = SequenceEntry(connector_id="conn_01", segment_id="seg_01") - entry2 = SequenceEntry(connector_id="conn_01", segment_id="seg_01") - entry3 = SequenceEntry(connector_id="conn_02", segment_id="seg_02") - - assert hash(entry1) == hash(entry2) - assert hash(entry1) != hash(entry3) - assert entry1 == entry2 - assert entry1 != entry3 - - def test_sequence_entry_with_different_connector_ids(self) -> None: - """Test SequenceEntry with different connector IDs.""" - entry1 = SequenceEntry(connector_id="conn_01", segment_id="seg_01") - entry2 = SequenceEntry(connector_id="conn_02", segment_id="seg_01") - - assert hash(entry1) != hash(entry2) - assert entry1 != entry2 - - def test_sequence_entry_with_different_segment_ids(self) -> None: - """Test SequenceEntry with different segment IDs.""" - entry1 = SequenceEntry(connector_id="conn_01", segment_id="seg_01") - entry2 = SequenceEntry(connector_id="conn_01", segment_id="seg_02") - - assert hash(entry1) != hash(entry2) - assert entry1 != entry2 - - def test_sequence_entry_in_set(self) -> None: - """Test that SequenceEntry can be used in sets.""" - entry1 = SequenceEntry(connector_id="conn_01", segment_id="seg_01") - entry2 = SequenceEntry(connector_id="conn_01", segment_id="seg_01") - entry3 = SequenceEntry(connector_id="conn_02", segment_id="seg_02") - - entry_set = {entry1, entry2, entry3} - assert len(entry_set) == 2