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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 222 additions & 1 deletion poetry.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cdot-wzdx-translator"
version = "1.4.5"
version = "1.4.6rc1"
description = "CDOT Work Zone WZDx Translators"
authors = ["CDOT <CDOT_rtdh_prod@state.co.us>"]
license = "MIT"
Expand All @@ -22,6 +22,8 @@ regex = ">=2024.4.16,<2026.0.0"
pyproj = "^3.4.0"
google-cloud-monitoring = "^2.13.0"
google-cloud-storage = ">=2.7.0,<4.0.0"
pydantic = {extras = ["email"], version = "^2.12.5"}
email-validator = "^2.3.0"

[tool.poetry.group.dev.dependencies]
pytest = ">=7.0.0,<10.0.0"
Expand Down
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
anyio==4.11.0 ; python_version >= "3.11" and python_version < "4.0"
attrs==25.4.0 ; python_version >= "3.11" and python_version < "4.0"
backports-tarfile==1.2.0 ; python_version == "3.11"
Expand All @@ -13,8 +14,10 @@ coverage==7.11.3 ; python_version >= "3.11" and python_version < "4.0"
crashtest==0.4.1 ; python_version >= "3.11" and python_version < "4.0"
cryptography==46.0.3 ; python_version >= "3.11" and python_version < "4.0" and sys_platform == "linux"
distlib==0.4.0 ; python_version >= "3.11" and python_version < "4.0"
dnspython==2.8.0 ; python_version >= "3.11" and python_version < "4.0"
docutils==0.22.3 ; python_version >= "3.11" and python_version < "4.0"
dulwich==0.24.10 ; python_version >= "3.11" and python_version < "4.0"
email-validator==2.3.0 ; python_version >= "3.11" and python_version < "4.0"
fastjsonschema==2.21.2 ; python_version >= "3.11" and python_version < "4.0"
filelock==3.20.0 ; python_version >= "3.11" and python_version < "4.0"
findpython==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
Expand Down Expand Up @@ -64,6 +67,8 @@ protobuf==6.33.0 ; python_version >= "3.11" and python_version < "4.0"
pyasn1-modules==0.4.2 ; python_version >= "3.11" and python_version < "4.0"
pyasn1==0.6.1 ; python_version >= "3.11" and python_version < "4.0"
pycparser==2.23 ; python_version >= "3.11" and python_version < "4.0" and (sys_platform == "darwin" or platform_python_implementation != "PyPy") and (sys_platform == "darwin" or sys_platform == "linux") and implementation_name != "PyPy"
pydantic-core==2.41.5 ; python_version >= "3.11" and python_version < "4.0"
pydantic==2.12.5 ; python_version >= "3.11" and python_version < "4.0"
pygments==2.19.2 ; python_version >= "3.11" and python_version < "4.0"
pyproj==3.7.2 ; python_version >= "3.11" and python_version < "4.0"
pyproject-hooks==1.2.0 ; python_version >= "3.11" and python_version < "4.0"
Expand Down Expand Up @@ -92,6 +97,7 @@ tomlkit==0.13.3 ; python_version >= "3.11" and python_version < "4.0"
trove-classifiers==2025.9.11.17 ; python_version >= "3.11" and python_version < "4.0"
twine==6.2.0 ; python_version >= "3.11" and python_version < "4.0"
typing-extensions==4.15.0 ; python_version >= "3.11" and python_version < "4.0"
typing-inspection==0.4.2 ; python_version >= "3.11" and python_version < "4.0"
urllib3==2.5.0 ; python_version >= "3.11" and python_version < "4.0"
virtualenv==20.35.4 ; python_version >= "3.11" and python_version < "4.0"
xattr==1.3.0 ; python_version >= "3.11" and python_version < "4.0" and sys_platform == "darwin"
Expand Down
57 changes: 57 additions & 0 deletions tests/data/models/field_device_feed_icone_final.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"feed_info": {
"update_date": "2025-12-18T20:34:51.150000Z",
"publisher": "iCone Products LLC",
"contact_email": "support@iconeproducts.com",
"version": "4.2",
"data_sources": [
{
"data_source_id": "67899A97-0F3E-4683-B169-75C09C3B8F67",
"update_date": "2025-12-18T20:34:51.150000Z",
"organization_name": "iCone Products LLC",
"contact_email": "support@iconeproducts.com"
}
]
},
"type": "FeatureCollection",
"features": [
{
"id": "E595E296-B1DE-4911-9454-1F2D54AC2EBD",
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-104.7752009, 39.4983242]
},
"properties": {
"core_details": {
"device_type": "arrow-board",
"data_source_id": "67899A97-0F3E-4683-B169-75C09C3B8F67",
"device_status": "ok",
"update_date": "2025-12-18T20:30:27Z",
"has_automatic_location": true,
"description": "Roadwork - Caution"
},
"pattern": "four-corners-flashing"
}
},
{
"id": "0E1E3B5B-D06E-4390-ABB3-C89091E246F0",
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-106.0079266, 39.6531149]
},
"properties": {
"core_details": {
"device_type": "location-marker",
"data_source_id": "67899A97-0F3E-4683-B169-75C09C3B8F67",
"device_status": "ok",
"update_date": "2025-12-18T20:19:13Z",
"has_automatic_location": true,
"description": "Roadwork Active"
},
"marked_locations": [{"type": "work-truck-with-lights-flashing"}]
}
}
]
}
94 changes: 94 additions & 0 deletions tests/data/models/field_device_feed_icone_raw.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
[
{
"feed_info": {
"update_date": "2025-12-18T20:34:51.1500000Z",
"publisher": "iCone Products LLC",
"contact_email": "support@iconeproducts.com",
"version": "4.2",
"data_sources": [
{
"data_source_id": "67899A97-0F3E-4683-B169-75C09C3B8F67",
"update_date": "2025-12-18T20:34:51.1500000Z",
"organization_name": "iCone Products LLC",
"contact_email": "support@iconeproducts.com"
}
],
"custom": {
"oldest_feature": "2025-12-17T20:34:51.0300000Z",
"oldest_location": "2025-12-17T20:34:51.0300000Z",
"username": "cdotfeeds",
"active_only": false,
"require_location": false,
"allow_custom_enums": true,
"include_custom": true,
"force_spec_required": false
}
},
"type": "FeatureCollection",
"features": [
{
"id": "E595E296-B1DE-4911-9454-1F2D54AC2EBD",
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-104.7752009,
39.4983242
]
},
"properties": {
"core_details": {
"device_type": "arrow-board",
"data_source_id": "67899A97-0F3E-4683-B169-75C09C3B8F67",
"device_status": "ok",
"update_date": "2025-12-18T20:30:27Z",
"has_automatic_location": true,
"description": "Roadwork - Caution"
},
"pattern": "four-corners-flashing",
"custom": {
"start_date": "2025-12-16T16:16:08",
"waze_incident": {
"type": "CONSTRUCTION",
"description": "Roadwork - Caution"
}
}
}
},
{
"id": "0E1E3B5B-D06E-4390-ABB3-C89091E246F0",
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-106.0079266,
39.6531149
]
},
"properties": {
"core_details": {
"device_type": "location-marker",
"data_source_id": "67899A97-0F3E-4683-B169-75C09C3B8F67",
"device_status": "ok",
"update_date": "2025-12-18T20:19:13Z",
"has_automatic_location": true,
"description": "Roadwork Active"
},
"marked_locations": [
{
"type": "work-truck-with-lights-flashing"
}
],
"custom": {
"isActive": true,
"start_date": "2025-12-18T20:08:16.1200000",
"waze_incident": {
"type": "HAZARD",
"description": "Roadwork Active"
}
}
}
}
]
}
]
57 changes: 57 additions & 0 deletions tests/models/field_device_feed_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from pydantic import TypeAdapter
from wzdx.models.field_device_feed.device_feed import DeviceFeed
import json


def test_deserialization():
"""Test deserialization and serialization of field device feed"""
# Load and deserialize JSON
with open("./tests/data/models/field_device_feed_icone_raw.json") as f:
json_string = f.read()

adapter = TypeAdapter(list[DeviceFeed])
device_feed_list: list[DeviceFeed] = adapter.validate_json(json_string)

# Validate structure
assert len(device_feed_list) == 1, "Expected exactly one device feed"
device_feed = device_feed_list[0]
assert len(device_feed.features) > 0, "Expected at least one feature"

# Validate first feature properties
first_feature = device_feed.features[0]
assert first_feature.id is not None, "Feature should have an ID"
assert first_feature.properties.core_details.device_status is not None
assert first_feature.properties.core_details.update_date is not None

# Load expected output
with open("./tests/data/models/field_device_feed_icone_final.json") as f:
expected_object = json.load(f)

# Compare serialized output with expected
actual_output = device_feed.model_dump(
by_alias=True, exclude_none=True, mode="json"
)
assert actual_output == expected_object, "Serialized output should match expected"


def test_roundtrip_serialization():
"""Test that serialize -> deserialize produces identical results"""
# Load original data
with open("./tests/data/models/field_device_feed_icone_raw.json") as f:
json_string = f.read()

adapter = TypeAdapter(list[DeviceFeed])

# First deserialization
device_feed_list_1 = adapter.validate_json(json_string)

# Serialize and deserialize again
json_output = adapter.dump_json(
device_feed_list_1, by_alias=True, exclude_none=True
)
device_feed_list_2 = adapter.validate_json(json_output)

# Should be identical
assert (
device_feed_list_1 == device_feed_list_2
), "Roundtrip serialization should be stable"
22 changes: 22 additions & 0 deletions tests/tools/date_tools_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ def test_parse_datetime_from_unix_invalid_dict():
assert actual == expected


# --------------------------------------------------------------------------------unit test for datetime_from_unix function--------------------------------------------------------------------------------
def test_datetime_from_unix_valid():
time = 1609398000
expected = datetime(2020, 12, 31, 7, tzinfo=timezone.utc)
actual = date_tools.datetime_from_unix(time)
assert actual == expected


def test_datetime_from_unix_decimal():
time = 1615866698.393723
expected = datetime(2021, 3, 16, 3, 51, 38, tzinfo=timezone.utc)
actual = date_tools.datetime_from_unix(time)
assert abs(actual - expected) < timedelta(seconds=1)


def test_datetime_from_unix_too_large():
time = 32536850400
expected = datetime(1971, 1, 12, 14, 0, 50, tzinfo=timezone.utc)
actual = date_tools.datetime_from_unix(time)
assert abs(actual - expected) < timedelta(seconds=1)


# --------------------------------------------------------------------------------unit test for parse_datetime_from_iso_string function--------------------------------------------------------------------------------
def test_parse_datetime_from_iso_string_valid():
time_string = "2020-12-31T07:00:00Z"
Expand Down
Empty file added wzdx/models/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions wzdx/models/feed_info/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .feed_info import FeedInfo
from .feed_data_source import FeedDataSource

__all__ = [
"FeedInfo",
"FeedDataSource"
]
40 changes: 40 additions & 0 deletions wzdx/models/feed_info/feed_data_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field


class FeedDataSource(BaseModel):
"""
The FeedDataSource object describes information about a specific data source used to build a data feed. A WZDx feed must contain at least one FeedDataSource, included as an entry in the data_sources array of the FeedInfo object.

Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FeedDataSource.md
"""

data_source_id: str = Field(
alias="data_source_id",
description="A unique identifier for the data source organization providing work zone data. It is recommended that this identifier is a Universally Unique Identifier (UUID) as defined in RFC 4122 to guarantee uniqueness between feeds and over time.",
)
organization_name: str = Field(
alias="organization_name",
description="The name of the organization for the authoritative source of the work zone data.",
)
update_date: Optional[datetime] = Field(
default=None,
alias="update_date",
description="The UTC date and time when the data source was last updated.",
)
update_frequency: Optional[int] = Field(
default=None,
alias="update_frequency",
description="The frequency in seconds at which the data source is updated.",
)
contact_name: Optional[str] = Field(
default=None,
alias="contact_name",
description="The name of the individual or group responsible for the data source.",
)
contact_email: Optional[EmailStr] = Field(
default=None,
alias="contact_email",
description="The email address of the individual or group responsible for the data source.",
)
49 changes: 49 additions & 0 deletions wzdx/models/feed_info/feed_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from typing import Optional
from pydantic import BaseModel, Field, EmailStr
from .feed_data_source import FeedDataSource
from datetime import datetime


class FeedInfo(BaseModel):
"""
The FeedInfo object describes WZDx feed header information such as metadata, contact information, and data sources. There is one FeedInfo per WZDx GeoJSON document.

Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FeedInfo.md
"""

publisher: str = Field(
alias="publisher",
description="The organization responsible for publishing the feed.",
)
version: str = Field(
alias="version",
description="The WZDx specification version used to create the data feed in major.minor format. Note this mandates that all data in a WZDx feed complies to a single version of WZDx.",
)
license: Optional[str] = Field(
default=None,
alias="license",
description='The URL of the license that applies to the data in the WZDx feed. This must be the string "https://creativecommons.org/publicdomain/zero/1.0/"',
)
data_sources: list[FeedDataSource] = Field(
alias="data_sources",
description="A list of specific data sources for the road event data in the feed.",
)
update_date: datetime = Field(
alias="update_date",
description="The UTC date and time when the GeoJSON file (representing the instance of the feed) was generated.",
)
update_frequency: Optional[int] = Field(
default=None,
alias="update_frequency",
description="The frequency in seconds at which the data feed is updated.",
)
contact_name: Optional[str] = Field(
default=None,
alias="contact_name",
description="The name of the individual or group responsible for the data feed.",
)
contact_email: Optional[EmailStr] = Field(
default=None,
alias="contact_email",
description="The email address of the individual or group responsible for the data feed.",
)
Loading