From 2bf2627c62782cba6d9fddacb6e373f2415122d3 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Fri, 19 Dec 2025 09:33:08 +1300 Subject: [PATCH 01/14] Updating GIS api to coloradodot.info and LrsServerRounded --- wzdx/tools/cdot_geospatial_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wzdx/tools/cdot_geospatial_api.py b/wzdx/tools/cdot_geospatial_api.py index 2523336..02fbb8c 100644 --- a/wzdx/tools/cdot_geospatial_api.py +++ b/wzdx/tools/cdot_geospatial_api.py @@ -18,7 +18,7 @@ def __init__( setCachedRequest: Callable[[str, str], None] = lambda url, response: None, BASE_URL: str = os.getenv( "CDOT_GEOSPATIAL_API_BASE_URL", - "https://dtdapps.codot.gov/server/rest/services/LRS/Routes_withDEC/MapServer/exts/CdotLrsAccessRounded", + "https://dtdapps.coloradodot.info/arcgis/rest/services/LRS/Routes/MapServer/exts/LrsServerRounded", ), ): """Initialize the Geospatial API From cfa14c94cf8d45e3f6abd517b952f3960249668b Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Fri, 19 Dec 2025 11:13:02 +1300 Subject: [PATCH 02/14] Adding deserializable field device feed objects --- poetry.lock | 224 +++++++++++++++++- pyproject.toml | 3 + tests/models/field_device_feed_test.py | 111 +++++++++ wzdx/models/feed_info/__init__.py | 8 + wzdx/models/feed_info/feed_data_source.py | 17 ++ wzdx/models/feed_info/feed_info.py | 25 ++ wzdx/models/field_device_feed/__init__.py | 48 ++++ wzdx/models/field_device_feed/device_feed.py | 16 ++ .../field_device_core_details.py | 26 ++ .../field_device_feed/field_device_feature.py | 13 + .../field_device_feed/field_device_status.py | 8 + .../field_device_feed/field_device_type.py | 12 + .../field_device_feed/properties/__init__.py | 29 +++ .../properties/arrow_board.py | 30 +++ .../field_device_feed/properties/camera.py | 12 + .../properties/dynamic_message_sign.py | 11 + .../properties/field_device_properties.py | 31 +++ .../properties/flashing_beacon.py | 32 +++ .../properties/hybrid_sign.py | 21 ++ .../properties/marked_location.py | 30 +++ .../properties/traffic_sensor.py | 23 ++ .../properties/traffic_signal.py | 22 ++ wzdx/models/geometry/__init__.py | 14 ++ wzdx/models/geometry/geojson_geometry.py | 18 ++ wzdx/models/geometry/geojson_linestring.py | 8 + wzdx/models/geometry/geojson_multipoint.py | 8 + wzdx/models/geometry/geojson_point.py | 8 + wzdx/models/geometry/geojson_polygon.py | 8 + .../sample_files/raw/icone/field_devices.json | 94 ++++++++ 29 files changed, 908 insertions(+), 2 deletions(-) create mode 100644 tests/models/field_device_feed_test.py create mode 100644 wzdx/models/feed_info/__init__.py create mode 100644 wzdx/models/feed_info/feed_data_source.py create mode 100644 wzdx/models/feed_info/feed_info.py create mode 100644 wzdx/models/field_device_feed/__init__.py create mode 100644 wzdx/models/field_device_feed/device_feed.py create mode 100644 wzdx/models/field_device_feed/field_device_core_details.py create mode 100644 wzdx/models/field_device_feed/field_device_feature.py create mode 100644 wzdx/models/field_device_feed/field_device_status.py create mode 100644 wzdx/models/field_device_feed/field_device_type.py create mode 100644 wzdx/models/field_device_feed/properties/__init__.py create mode 100644 wzdx/models/field_device_feed/properties/arrow_board.py create mode 100644 wzdx/models/field_device_feed/properties/camera.py create mode 100644 wzdx/models/field_device_feed/properties/dynamic_message_sign.py create mode 100644 wzdx/models/field_device_feed/properties/field_device_properties.py create mode 100644 wzdx/models/field_device_feed/properties/flashing_beacon.py create mode 100644 wzdx/models/field_device_feed/properties/hybrid_sign.py create mode 100644 wzdx/models/field_device_feed/properties/marked_location.py create mode 100644 wzdx/models/field_device_feed/properties/traffic_sensor.py create mode 100644 wzdx/models/field_device_feed/properties/traffic_signal.py create mode 100644 wzdx/models/geometry/__init__.py create mode 100644 wzdx/models/geometry/geojson_geometry.py create mode 100644 wzdx/models/geometry/geojson_linestring.py create mode 100644 wzdx/models/geometry/geojson_multipoint.py create mode 100644 wzdx/models/geometry/geojson_point.py create mode 100644 wzdx/models/geometry/geojson_polygon.py create mode 100644 wzdx/sample_files/raw/icone/field_devices.json diff --git a/poetry.lock b/poetry.lock index f76dcac..c71584e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "anyio" version = "4.11.0" @@ -573,6 +585,27 @@ files = [ {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + [[package]] name = "docutils" version = "0.22.3" @@ -641,6 +674,22 @@ paramiko = ["paramiko"] patiencediff = ["patiencediff"] pgp = ["gpg"] +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "fastjsonschema" version = "2.21.2" @@ -1782,6 +1831,163 @@ files = [ {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pygments" version = "2.19.2" @@ -2721,7 +2927,21 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version < \"3.13\""} + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" [[package]] name = "urllib3" @@ -2980,4 +3200,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "6b3c6b2de2d5ea6aa1f27f14afe34e410b47ad4e1d78f310c0bf71d652213536" +content-hash = "cfda40bcaaf4fcd5daabf584164d4c5bf7abddbd0f8e5eec8dafa20e090a15d5" diff --git a/pyproject.toml b/pyproject.toml index f270177..84f668b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -29,6 +31,7 @@ pytest-cov = ">=5.0.0,<8.0.0" time-machine = "^2.10.0" poetry-plugin-export = "^1.6.0" twine = ">=5.0.0,<7.0.0" +pydantic = {extras = ["email"], version = "^2.12.5"} [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] diff --git a/tests/models/field_device_feed_test.py b/tests/models/field_device_feed_test.py new file mode 100644 index 0000000..8b42cef --- /dev/null +++ b/tests/models/field_device_feed_test.py @@ -0,0 +1,111 @@ +from wzdx.models.field_device_feed.device_feed import DeviceFeed + +def test_deserialization(): + # Deserialize from JSON string + json_string = ''' + { + "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" + } + } + } + } + ] + } + ''' + + device_feed: DeviceFeed = DeviceFeed.model_validate_json(json_string) + + # Serialize to JSON + json_output = device_feed.model_dump_json(by_alias=True, exclude_none=True) + + # Access properties + for feature in device_feed.features: + print(f"Device ID: {feature.id}") + print(f"Status: {feature.properties.core_details.device_status}") + + print("JSON Output", json_output) + assert False \ No newline at end of file diff --git a/wzdx/models/feed_info/__init__.py b/wzdx/models/feed_info/__init__.py new file mode 100644 index 0000000..c84db87 --- /dev/null +++ b/wzdx/models/feed_info/__init__.py @@ -0,0 +1,8 @@ +# wzdx/__init__.py +from .feed_info import FeedInfo +from .feed_data_source import FeedDataSource + +__all__ = [ + "FeedInfo", + "FeedDataSource" +] \ No newline at end of file diff --git a/wzdx/models/feed_info/feed_data_source.py b/wzdx/models/feed_info/feed_data_source.py new file mode 100644 index 0000000..43ad46a --- /dev/null +++ b/wzdx/models/feed_info/feed_data_source.py @@ -0,0 +1,17 @@ +# wzdx/feed_data_source.py +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field + +from ..enums import LocationMethod + +class FeedDataSource(BaseModel): + """WZDx feed data source""" + data_source_id: str = Field(alias="data_source_id") + organization_name: Optional[str] = Field(None, alias="organization_name") + contact_name: Optional[str] = Field(None, alias="contact_name") + contact_email: Optional[str] = Field(None, alias="contact_email") + update_frequency: Optional[int] = Field(None, alias="update_frequency") + update_date: Optional[str] = Field(None, alias="update_date") + location_method: Optional[LocationMethod] = Field(None, alias="location_method") + lrs_type: Optional[str] = Field(None, alias="lrs_type") + lrs_url: Optional[str] = Field(None, alias="lrs_url") \ No newline at end of file diff --git a/wzdx/models/feed_info/feed_info.py b/wzdx/models/feed_info/feed_info.py new file mode 100644 index 0000000..6502ed6 --- /dev/null +++ b/wzdx/models/feed_info/feed_info.py @@ -0,0 +1,25 @@ +# wzdx/feed_info.py +from typing import Optional +from pydantic import BaseModel, Field, EmailStr +from .feed_data_source import FeedDataSource + +class FeedInfoIconeCustom(BaseModel): + """Custom iCone properties""" + # Add iCone custom fields here based on FeedInfoIconeCustom.java + pass + +class FeedInfo(BaseModel): + """ + WZDx feed info metadata. + + See: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FeedInfo.md + """ + publisher: Optional[str] = None + version: Optional[str] = None + license: Optional[str] = None + data_sources: Optional[list[FeedDataSource]] = Field(None, alias="data_sources") + update_date: Optional[str] = Field(None, alias="update_date") + update_frequency: Optional[int] = Field(None, alias="update_frequency") + contact_name: Optional[str] = Field(None, alias="contact_name") + contact_email: Optional[EmailStr] = Field(None, alias="contact_email") + custom: Optional[FeedInfoIconeCustom] = None \ No newline at end of file diff --git a/wzdx/models/field_device_feed/__init__.py b/wzdx/models/field_device_feed/__init__.py new file mode 100644 index 0000000..a187d0b --- /dev/null +++ b/wzdx/models/field_device_feed/__init__.py @@ -0,0 +1,48 @@ +# device_feed/__init__.py +from .device_feed import DeviceFeed +from .field_device_feature import FieldDeviceFeature +from .field_device_core_details import FieldDeviceCoreDetails +from .field_device_status import FieldDeviceStatus +from .field_device_type import FieldDeviceType +from .properties import ( + FieldDeviceProperties, + ArrowBoard, + ArrowBoardPattern, + Camera, + DynamicMessageSign, + FlashingBeacon, + FlashingBeaconFunction, + HybridSign, + HybridSignDynamicMessageFunction, + LocationMarker, + MarkedLocation, + MarkedLocationType, + TrafficSensor, + TrafficSensorLaneData, + TrafficSignal, + TrafficSignalMode, +) + +__all__ = [ + "DeviceFeed", + "FieldDeviceFeature", + "FieldDeviceCoreDetails", + "FieldDeviceStatus", + "FieldDeviceType", + "FieldDeviceProperties", + "ArrowBoard", + "ArrowBoardPattern", + "Camera", + "DynamicMessageSign", + "FlashingBeacon", + "FlashingBeaconFunction", + "HybridSign", + "HybridSignDynamicMessageFunction", + "LocationMarker", + "MarkedLocation", + "MarkedLocationType", + "TrafficSensor", + "TrafficSensorLaneData", + "TrafficSignal", + "TrafficSignalMode", +] \ No newline at end of file diff --git a/wzdx/models/field_device_feed/device_feed.py b/wzdx/models/field_device_feed/device_feed.py new file mode 100644 index 0000000..b7f1eb5 --- /dev/null +++ b/wzdx/models/field_device_feed/device_feed.py @@ -0,0 +1,16 @@ +# device_feed/device_feed.py +from typing import Optional +from pydantic import BaseModel, Field + +from ..feed_info.feed_info import FeedInfo + +from .field_device_feature import FieldDeviceFeature + +# Note: You'll need to implement FeedInfo from the wzdx package +# from ..wzdx import FeedInfo + +class DeviceFeed(BaseModel): + feed_info: FeedInfo = Field(alias="feed_info") + type: str + features: list[FieldDeviceFeature] + bbox: Optional[list[float]] = None \ No newline at end of file diff --git a/wzdx/models/field_device_feed/field_device_core_details.py b/wzdx/models/field_device_feed/field_device_core_details.py new file mode 100644 index 0000000..3977957 --- /dev/null +++ b/wzdx/models/field_device_feed/field_device_core_details.py @@ -0,0 +1,26 @@ +# device_feed/field_device_core_details.py +from typing import Optional +from pydantic import BaseModel, Field +from datetime import datetime + +from .field_device_type import FieldDeviceType +from .field_device_status import FieldDeviceStatus + +class FieldDeviceCoreDetails(BaseModel): + device_type: Optional[FieldDeviceType] = Field(None, alias="device_type") + road_event_id: Optional[str] = Field(None, alias="road_event_id") + data_source_id: Optional[str] = Field(None, alias="data_source_id") + road_names: Optional[list[str]] = Field(None, alias="road_names") + name: Optional[str] = None + description: Optional[str] = None + device_status: Optional[FieldDeviceStatus] = Field(None, alias="device_status") + update_date: Optional[datetime] = Field(None, alias="update_date") + has_automatic_location: Optional[bool] = Field(None, alias="has_automatic_location") + velocity_kph: Optional[float] = Field(None, alias="velocity_kph") + is_moving: Optional[bool] = Field(None, alias="is_moving") + road_direction: Optional[str] = Field(None, alias="road_direction") + make: Optional[str] = None + model: Optional[str] = None + serial_number: Optional[str] = Field(None, alias="serial_number") + firmware_version: Optional[str] = Field(None, alias="firmware_version") + milepost: Optional[float] = None \ No newline at end of file diff --git a/wzdx/models/field_device_feed/field_device_feature.py b/wzdx/models/field_device_feed/field_device_feature.py new file mode 100644 index 0000000..47ba2a3 --- /dev/null +++ b/wzdx/models/field_device_feed/field_device_feature.py @@ -0,0 +1,13 @@ +# device_feed/field_device_feature.py +from typing import Optional +from pydantic import BaseModel + +from ..geometry.geojson_geometry import GeoJsonGeometry +from .properties.field_device_properties import FieldDeviceProperties + +class FieldDeviceFeature(BaseModel): + id: str + type: str + properties: FieldDeviceProperties + geometry: GeoJsonGeometry # GeoJSON geometry object + bbox: Optional[list[float]] = None \ No newline at end of file diff --git a/wzdx/models/field_device_feed/field_device_status.py b/wzdx/models/field_device_feed/field_device_status.py new file mode 100644 index 0000000..03a6cc3 --- /dev/null +++ b/wzdx/models/field_device_feed/field_device_status.py @@ -0,0 +1,8 @@ +# device_feed/field_device_status.py +from enum import Enum + +class FieldDeviceStatus(str, Enum): + ERROR = "error" + OK = "ok" + UNKNOWN = "unknown" + WARNING = "warning" \ No newline at end of file diff --git a/wzdx/models/field_device_feed/field_device_type.py b/wzdx/models/field_device_feed/field_device_type.py new file mode 100644 index 0000000..8c86d85 --- /dev/null +++ b/wzdx/models/field_device_feed/field_device_type.py @@ -0,0 +1,12 @@ +# device_feed/field_device_type.py +from enum import Enum + +class FieldDeviceType(str, Enum): + ARROW_BOARD = "arrow-board" + CAMERA = "camera" + DYNAMIC_MESSAGE_SIGN = "dynamic-message-sign" + FLASHING_BEACON = "flashing-beacon" + HYBRID_SIGN = "hybrid-sign" + LOCATION_MARKER = "location-marker" + TRAFFIC_SENSOR = "traffic-sensor" + TRAFFIC_SIGNAL = "traffic-signal" \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/__init__.py b/wzdx/models/field_device_feed/properties/__init__.py new file mode 100644 index 0000000..8629296 --- /dev/null +++ b/wzdx/models/field_device_feed/properties/__init__.py @@ -0,0 +1,29 @@ +# device_feed/properties/__init__.py +from .field_device_properties import FieldDeviceProperties +from .arrow_board import ArrowBoard, ArrowBoardPattern +from .camera import Camera +from .dynamic_message_sign import DynamicMessageSign +from .flashing_beacon import FlashingBeacon, FlashingBeaconFunction +from .hybrid_sign import HybridSign, HybridSignDynamicMessageFunction +from .marked_location import LocationMarker, MarkedLocation, MarkedLocationType +from .traffic_sensor import TrafficSensor, TrafficSensorLaneData +from .traffic_signal import TrafficSignal, TrafficSignalMode + +__all__ = [ + "FieldDeviceProperties", + "ArrowBoard", + "ArrowBoardPattern", + "Camera", + "DynamicMessageSign", + "FlashingBeacon", + "FlashingBeaconFunction", + "HybridSign", + "HybridSignDynamicMessageFunction", + "LocationMarker", + "MarkedLocation", + "MarkedLocationType", + "TrafficSensor", + "TrafficSensorLaneData", + "TrafficSignal", + "TrafficSignalMode", +] \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/arrow_board.py b/wzdx/models/field_device_feed/properties/arrow_board.py new file mode 100644 index 0000000..7eaab6f --- /dev/null +++ b/wzdx/models/field_device_feed/properties/arrow_board.py @@ -0,0 +1,30 @@ +# device_feed/properties/arrow_board.py +from typing import Literal, Optional +from pydantic import BaseModel, Field +from enum import Enum +from ..field_device_core_details import FieldDeviceCoreDetails + +class ArrowBoardPattern(str, Enum): + BLANK = "blank" + RIGHT_ARROW_STATIC = "right-arrow-static" + RIGHT_ARROW_FLASHING = "right-arrow-flashing" + RIGHT_CHEVRON_STATIC = "right-chevron-static" + RIGHT_CHEVRON_FLASHING = "right-chevron-flashing" + LEFT_ARROW_STATIC = "left-arrow-static" + LEFT_ARROW_FLASHING = "left-arrow-flashing" + LEFT_CHEVRON_STATIC = "left-chevron-static" + LEFT_CHEVRON_FLASHING = "left-chevron-flashing" + BIDIRECTIONAL_ARROW_STATIC = "bidirectional-arrow-static" + BIDIRECTIONAL_ARROW_FLASHING = "bidirectional-arrow-flashing" + FOUR_CORNERS_FLASHING = "four-corners-flashing" + LINE_FLASHING = "line-flashing" + DIAMONDS_ALTERNATING = "diamonds-alternating" + BAR_FLASHING = "bar-flashing" + UNKNOWN = "unknown" + +class ArrowBoardCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["arrow-board"] = "arrow-board" + +class ArrowBoard(BaseModel): + core_details: ArrowBoardCoreDetails = Field(alias="core_details") + pattern: Optional[ArrowBoardPattern] = None \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/camera.py b/wzdx/models/field_device_feed/properties/camera.py new file mode 100644 index 0000000..baef94a --- /dev/null +++ b/wzdx/models/field_device_feed/properties/camera.py @@ -0,0 +1,12 @@ +# device_feed/properties/camera.py +from typing import Literal, Optional +from pydantic import BaseModel, Field, HttpUrl +from ..field_device_core_details import FieldDeviceCoreDetails + +class CameraCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["camera"] = "camera" + +class Camera(BaseModel): + core_details: CameraCoreDetails = Field(alias="core_details") + image_url: Optional[HttpUrl] = Field(None, alias="image_url") + image_timestamp: Optional[str] = Field(None, alias="image_timestamp") \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/dynamic_message_sign.py b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py new file mode 100644 index 0000000..d52d233 --- /dev/null +++ b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py @@ -0,0 +1,11 @@ +# device_feed/properties/dynamic_message_sign.py +from typing import Literal, Optional +from pydantic import BaseModel, Field +from ..field_device_core_details import FieldDeviceCoreDetails + +class DynamicMessageSignCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["dynamic-message-sign"] = "dynamic-message-sign" + +class DynamicMessageSign(BaseModel): + core_details: DynamicMessageSignCoreDetails = Field(alias="core_details") + message_multi_string: Optional[str] = Field(None, alias="message_multi_string") \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/field_device_properties.py b/wzdx/models/field_device_feed/properties/field_device_properties.py new file mode 100644 index 0000000..2dac960 --- /dev/null +++ b/wzdx/models/field_device_feed/properties/field_device_properties.py @@ -0,0 +1,31 @@ +# device_feed/properties/field_device_properties.py +from typing import Annotated, Union + +from pydantic import Discriminator, Tag +from .arrow_board import ArrowBoard +from .camera import Camera +from .dynamic_message_sign import DynamicMessageSign +from .flashing_beacon import FlashingBeacon +from .hybrid_sign import HybridSign +from .marked_location import MarkedLocation +from .traffic_sensor import TrafficSensor +from .traffic_signal import TrafficSignal + +def get_device_type(v): + """Discriminator function to get device_type from core_details""" + if isinstance(v, dict): + return v.get("core_details", {}).get("device_type") + return getattr(v.core_details, "device_type", None) + +# Discriminated union based on core_details.device_type +FieldDeviceProperties = Annotated[ + Annotated[ArrowBoard, Tag("arrow-board")] | + Annotated[Camera, Tag("camera")] | + Annotated[DynamicMessageSign, Tag("dynamic-message-sign")] | + Annotated[FlashingBeacon, Tag("flashing-beacon")] | + Annotated[HybridSign, Tag("hybrid-sign")] | + Annotated[MarkedLocation, Tag("location-marker")] | + Annotated[TrafficSensor, Tag("traffic-sensor")] | + Annotated[TrafficSignal, Tag("traffic-signal")], + Discriminator(get_device_type), +] \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/flashing_beacon.py b/wzdx/models/field_device_feed/properties/flashing_beacon.py new file mode 100644 index 0000000..02a7707 --- /dev/null +++ b/wzdx/models/field_device_feed/properties/flashing_beacon.py @@ -0,0 +1,32 @@ +# device_feed/properties/flashing_beacon.py +from typing import Literal, Optional +from pydantic import BaseModel, Field +from enum import Enum +from ..field_device_core_details import FieldDeviceCoreDetails + +class FlashingBeaconFunction(str, Enum): + VEHICLE_ENTERING = "vehicle-entering" + QUEUE_WARNING = "queue-warning" + REDUCED_SPEED = "reduced-speed" + WORKERS_PRESENT = "workers-present" + FLAGGER_PRESENT = "flagger-present" + ROAD_WORK = "road-work" + UTILITY_WORK = "utility-work" + MAINTENANCE_WORK = "maintenance-work" + CONSTRUCTION = "construction" + INCIDENT = "incident" + EMERGENCY = "emergency" + CONGESTION = "congestion" + WEATHER = "weather" + SCHOOL_ZONE = "school-zone" + PEDESTRIAN_CROSSING = "pedestrian-crossing" + OTHER = "other" + UNKNOWN = "unknown" + +class FlashingBeaconCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["flashing-beacon"] = "flashing-beacon" + +class FlashingBeacon(BaseModel): + core_details: FlashingBeaconCoreDetails = Field(alias="core_details") + function: Optional[FlashingBeaconFunction] = None + is_flashing: Optional[bool] = Field(None, alias="is_flashing") \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/hybrid_sign.py b/wzdx/models/field_device_feed/properties/hybrid_sign.py new file mode 100644 index 0000000..c75c225 --- /dev/null +++ b/wzdx/models/field_device_feed/properties/hybrid_sign.py @@ -0,0 +1,21 @@ +# device_feed/properties/hybrid_sign.py +from typing import Literal, Optional +from pydantic import BaseModel, Field +from enum import Enum +from ..field_device_core_details import FieldDeviceCoreDetails + +class HybridSignDynamicMessageFunction(str, Enum): + SPEED_LIMIT = "speed-limit" + TRAVEL_TIME = "travel-time" + OTHER = "other" + UNKNOWN = "unknown" + +class HybridSignCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["hybrid-sign"] = "hybrid-sign" + +class HybridSign(BaseModel): + core_details: HybridSignCoreDetails = Field(alias="core_details") + dynamic_message_function: Optional[HybridSignDynamicMessageFunction] = Field( + None, alias="dynamic_message_function" + ) + dynamic_message_text: Optional[str] = Field(None, alias="dynamic_message_text") \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/marked_location.py b/wzdx/models/field_device_feed/properties/marked_location.py new file mode 100644 index 0000000..ce2e74c --- /dev/null +++ b/wzdx/models/field_device_feed/properties/marked_location.py @@ -0,0 +1,30 @@ +# device_feed/properties/marked_location.py +from typing import Literal, Optional +from pydantic import BaseModel, Field +from enum import Enum +from ..field_device_core_details import FieldDeviceCoreDetails + +class MarkedLocationType(str, Enum): + AFAD = "afad" + FLAGGER = "flagger" + LANE_SHIFT = "lane-shift" + LANE_CLOSURE = "lane-closure" + PERSONAL_DEVICE = "personal-device" + RAMP_CLOSURE = "ramp-closure" + ROAD_CLOSURE = "road-closure" + ROAD_EVENT_START = "road-event-start" + ROAD_EVENT_END = "road-event-end" + WORK_TRUCK_WITH_LIGHTS_FLASHING = "work-truck-with-lights-flashing" + WORK_ZONE_START = "work-zone-start" + WORK_ZONE_END = "work-zone-end" + +class LocationMarker(BaseModel): + type: MarkedLocationType + description: Optional[str] = None + +class MarkedLocationCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["location-marker"] = "location-marker" + +class MarkedLocation(BaseModel): + core_details: MarkedLocationCoreDetails = Field(alias="core_details") + marked_locations: Optional[list[LocationMarker]] = Field(None, alias="marked_locations") \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/traffic_sensor.py b/wzdx/models/field_device_feed/properties/traffic_sensor.py new file mode 100644 index 0000000..414afeb --- /dev/null +++ b/wzdx/models/field_device_feed/properties/traffic_sensor.py @@ -0,0 +1,23 @@ +# device_feed/properties/traffic_sensor.py +from typing import Literal, Optional +from pydantic import BaseModel, Field +from ..field_device_core_details import FieldDeviceCoreDetails + +class TrafficSensorLaneData(BaseModel): + lane_order: int = Field(alias="lane_order") + average_speed_kph: Optional[float] = Field(None, alias="average_speed_kph") + volume_vph: Optional[int] = Field(None, alias="volume_vph") + occupancy_percent: Optional[float] = Field(None, alias="occupancy_percent") + +class TrafficSensorCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["traffic-sensor"] = "traffic-sensor" + +class TrafficSensor(BaseModel): + core_details: TrafficSensorCoreDetails = Field(alias="core_details") + collection_interval_start_date: Optional[str] = Field( + None, alias="collection_interval_start_date" + ) + collection_interval_end_date: Optional[str] = Field( + None, alias="collection_interval_end_date" + ) + lane_data: Optional[list[TrafficSensorLaneData]] = Field(None, alias="lane_data") \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/traffic_signal.py b/wzdx/models/field_device_feed/properties/traffic_signal.py new file mode 100644 index 0000000..34ec371 --- /dev/null +++ b/wzdx/models/field_device_feed/properties/traffic_signal.py @@ -0,0 +1,22 @@ +# device_feed/properties/traffic_signal.py +from typing import Literal, Optional +from pydantic import BaseModel, Field +from enum import Enum +from ..field_device_core_details import FieldDeviceCoreDetails + +class TrafficSignalMode(str, Enum): + BLANK = "blank" + FLASHING_RED = "flashing-red" + FLASHING_YELLOW = "flashing-yellow" + FULLY_ACTUATED = "fully-actuated" + MANUAL = "manual" + PRE_TIMED = "pre-timed" + SEMI_ACTUATED = "semi-actuated" + UNKNOWN = "unknown" + +class TrafficSignalCoreDetails(FieldDeviceCoreDetails): + device_type: Literal["traffic-signal"] = "traffic-signal" + +class TrafficSignal(BaseModel): + core_details: TrafficSignalCoreDetails = Field(alias="core_details") + mode: Optional[TrafficSignalMode] = None \ No newline at end of file diff --git a/wzdx/models/geometry/__init__.py b/wzdx/models/geometry/__init__.py new file mode 100644 index 0000000..160b3d6 --- /dev/null +++ b/wzdx/models/geometry/__init__.py @@ -0,0 +1,14 @@ +# wzdx/geometry/__init__.py +from .geojson_geometry import GeoJsonGeometry +from .geojson_point import GeoJsonPoint +from .geojson_linestring import GeoJsonLineString +from .geojson_multipoint import GeoJsonMultiPoint +from .geojson_polygon import GeoJsonPolygon + +__all__ = [ + "GeoJsonGeometry", + "GeoJsonPoint", + "GeoJsonLineString", + "GeoJsonMultiPoint", + "GeoJsonPolygon", +] \ No newline at end of file diff --git a/wzdx/models/geometry/geojson_geometry.py b/wzdx/models/geometry/geojson_geometry.py new file mode 100644 index 0000000..419735c --- /dev/null +++ b/wzdx/models/geometry/geojson_geometry.py @@ -0,0 +1,18 @@ +# wzdx/geometry/geojson_geometry.py +from typing import Annotated, Union + +from pydantic import Field +from .geojson_point import GeoJsonPoint +from .geojson_linestring import GeoJsonLineString +from .geojson_multipoint import GeoJsonMultiPoint +from .geojson_polygon import GeoJsonPolygon + +GeoJsonGeometry = Annotated[ + Union[ + GeoJsonPoint, + GeoJsonLineString, + GeoJsonMultiPoint, + GeoJsonPolygon, + ], + Field(discriminator="type"), +] \ No newline at end of file diff --git a/wzdx/models/geometry/geojson_linestring.py b/wzdx/models/geometry/geojson_linestring.py new file mode 100644 index 0000000..0a47647 --- /dev/null +++ b/wzdx/models/geometry/geojson_linestring.py @@ -0,0 +1,8 @@ +# wzdx/geometry/geojson_linestring.py +from typing import Literal +from pydantic import BaseModel + +class GeoJsonLineString(BaseModel): + """GeoJSON LineString geometry""" + type: Literal["LineString"] + coordinates: list[list[float]] # Array of positions \ No newline at end of file diff --git a/wzdx/models/geometry/geojson_multipoint.py b/wzdx/models/geometry/geojson_multipoint.py new file mode 100644 index 0000000..8d33c5f --- /dev/null +++ b/wzdx/models/geometry/geojson_multipoint.py @@ -0,0 +1,8 @@ +# wzdx/geometry/geojson_multipoint.py +from typing import Literal +from pydantic import BaseModel + +class GeoJsonMultiPoint(BaseModel): + """GeoJSON MultiPoint geometry""" + type: Literal["MultiPoint"] + coordinates: list[list[float]] # Array of positions \ No newline at end of file diff --git a/wzdx/models/geometry/geojson_point.py b/wzdx/models/geometry/geojson_point.py new file mode 100644 index 0000000..b4cdfcb --- /dev/null +++ b/wzdx/models/geometry/geojson_point.py @@ -0,0 +1,8 @@ +# wzdx/geometry/geojson_point.py +from typing import Literal +from pydantic import BaseModel + +class GeoJsonPoint(BaseModel): + """GeoJSON Point geometry""" + type: Literal["Point"] + coordinates: list[float] # [longitude, latitude] or [longitude, latitude, elevation] \ No newline at end of file diff --git a/wzdx/models/geometry/geojson_polygon.py b/wzdx/models/geometry/geojson_polygon.py new file mode 100644 index 0000000..5a0810a --- /dev/null +++ b/wzdx/models/geometry/geojson_polygon.py @@ -0,0 +1,8 @@ +# wzdx/geometry/geojson_polygon.py +from typing import Literal +from pydantic import BaseModel + +class GeoJsonPolygon(BaseModel): + """GeoJSON Polygon geometry""" + type: Literal["Polygon"] + coordinates: list[list[list[float]]] # Array of linear rings \ No newline at end of file diff --git a/wzdx/sample_files/raw/icone/field_devices.json b/wzdx/sample_files/raw/icone/field_devices.json new file mode 100644 index 0000000..194ffa8 --- /dev/null +++ b/wzdx/sample_files/raw/icone/field_devices.json @@ -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" + } + } + } + } + ] + } +] \ No newline at end of file From 65d8ec4705e8284d77cd62cf6bd816090f48be5a Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Mon, 22 Dec 2025 12:44:40 -0500 Subject: [PATCH 03/14] validating field device objects and deserialization --- pyproject.toml | 2 +- tests/models/field_device_feed_test.py | 27 ++- wzdx/models/__init__.py | 0 wzdx/models/feed_info/feed_data_source.py | 14 +- wzdx/models/feed_info/feed_info.py | 10 +- .../field_device_core_details.py | 21 +- .../field_device_feed/properties/__init__.py | 4 +- .../properties/arrow_board.py | 9 +- .../field_device_feed/properties/camera.py | 5 +- .../properties/dynamic_message_sign.py | 6 +- .../properties/field_device_properties.py | 22 +- .../properties/flashing_beacon.py | 18 +- .../properties/hybrid_sign.py | 4 +- ...{marked_location.py => location_marker.py} | 9 +- .../properties/traffic_sensor.py | 12 +- .../properties/traffic_signal.py | 4 +- wzdx/raw_to_standard/icone.py | 60 +++--- wzdx/tools/date_tools.py | 196 +++++++++--------- 18 files changed, 211 insertions(+), 212 deletions(-) create mode 100644 wzdx/models/__init__.py rename wzdx/models/field_device_feed/properties/{marked_location.py => location_marker.py} (85%) diff --git a/pyproject.toml b/pyproject.toml index 84f668b..19c1b64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT" diff --git a/tests/models/field_device_feed_test.py b/tests/models/field_device_feed_test.py index 8b42cef..fcc14e1 100644 --- a/tests/models/field_device_feed_test.py +++ b/tests/models/field_device_feed_test.py @@ -1,8 +1,10 @@ +from pydantic import TypeAdapter from wzdx.models.field_device_feed.device_feed import DeviceFeed def test_deserialization(): # Deserialize from JSON string - json_string = ''' + json_string = """ + [ { "feed_info": { "update_date": "2025-12-18T20:34:51.1500000Z", @@ -95,17 +97,24 @@ def test_deserialization(): } ] } - ''' + ] + """ - device_feed: DeviceFeed = DeviceFeed.model_validate_json(json_string) + adapter = TypeAdapter(list[DeviceFeed]) + device_feed_list: list[DeviceFeed] = adapter.validate_json(json_string) # Serialize to JSON - json_output = device_feed.model_dump_json(by_alias=True, exclude_none=True) + json_output = adapter.dump_json(device_feed_list, by_alias=True, exclude_none=True) # Access properties - for feature in device_feed.features: - print(f"Device ID: {feature.id}") - print(f"Status: {feature.properties.core_details.device_status}") + if device_feed_list and len(device_feed_list) > 0: + device_feed = device_feed_list[0] + for feature in device_feed.features: + print(f"Device ID: {feature.id}") + print(f"Status: {feature.properties.core_details.device_status}") + print( + f"Update Date: {feature.properties.core_details.update_date}, {type(feature.properties.core_details.update_date)}" + ) - print("JSON Output", json_output) - assert False \ No newline at end of file + print("JSON Output", json_output) + assert False diff --git a/wzdx/models/__init__.py b/wzdx/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wzdx/models/feed_info/feed_data_source.py b/wzdx/models/feed_info/feed_data_source.py index 43ad46a..5ee3862 100644 --- a/wzdx/models/feed_info/feed_data_source.py +++ b/wzdx/models/feed_info/feed_data_source.py @@ -1,17 +1,13 @@ # wzdx/feed_data_source.py from typing import Optional -from pydantic import BaseModel, ConfigDict, Field - -from ..enums import LocationMethod +from datetime import datetime +from pydantic import BaseModel, Field class FeedDataSource(BaseModel): """WZDx feed data source""" data_source_id: str = Field(alias="data_source_id") - organization_name: Optional[str] = Field(None, alias="organization_name") + organization_name: str = Field(None, alias="organization_name") + update_date: Optional[datetime] = Field(None, alias="update_date") + update_frequency: Optional[int] = Field(None, alias="update_frequency") contact_name: Optional[str] = Field(None, alias="contact_name") contact_email: Optional[str] = Field(None, alias="contact_email") - update_frequency: Optional[int] = Field(None, alias="update_frequency") - update_date: Optional[str] = Field(None, alias="update_date") - location_method: Optional[LocationMethod] = Field(None, alias="location_method") - lrs_type: Optional[str] = Field(None, alias="lrs_type") - lrs_url: Optional[str] = Field(None, alias="lrs_url") \ No newline at end of file diff --git a/wzdx/models/feed_info/feed_info.py b/wzdx/models/feed_info/feed_info.py index 6502ed6..f84c56c 100644 --- a/wzdx/models/feed_info/feed_info.py +++ b/wzdx/models/feed_info/feed_info.py @@ -2,6 +2,7 @@ from typing import Optional from pydantic import BaseModel, Field, EmailStr from .feed_data_source import FeedDataSource +from datetime import datetime class FeedInfoIconeCustom(BaseModel): """Custom iCone properties""" @@ -14,12 +15,11 @@ class FeedInfo(BaseModel): See: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FeedInfo.md """ - publisher: Optional[str] = None - version: Optional[str] = None + publisher: str = None + version: str = None license: Optional[str] = None - data_sources: Optional[list[FeedDataSource]] = Field(None, alias="data_sources") - update_date: Optional[str] = Field(None, alias="update_date") + data_sources: list[FeedDataSource] = Field(None, alias="data_sources") + update_date: datetime = Field(None, alias="update_date") update_frequency: Optional[int] = Field(None, alias="update_frequency") contact_name: Optional[str] = Field(None, alias="contact_name") contact_email: Optional[EmailStr] = Field(None, alias="contact_email") - custom: Optional[FeedInfoIconeCustom] = None \ No newline at end of file diff --git a/wzdx/models/field_device_feed/field_device_core_details.py b/wzdx/models/field_device_feed/field_device_core_details.py index 3977957..294dc41 100644 --- a/wzdx/models/field_device_feed/field_device_core_details.py +++ b/wzdx/models/field_device_feed/field_device_core_details.py @@ -1,26 +1,27 @@ -# device_feed/field_device_core_details.py from typing import Optional from pydantic import BaseModel, Field from datetime import datetime from .field_device_type import FieldDeviceType from .field_device_status import FieldDeviceStatus +from ..enums import Direction class FieldDeviceCoreDetails(BaseModel): - device_type: Optional[FieldDeviceType] = Field(None, alias="device_type") - road_event_id: Optional[str] = Field(None, alias="road_event_id") - data_source_id: Optional[str] = Field(None, alias="data_source_id") + device_type: FieldDeviceType = Field(None, alias="device_type") + data_source_id: str = Field(None, alias="data_source_id") + device_status: FieldDeviceStatus = Field(None, alias="device_status") + update_date: datetime = Field(None, alias="update_date") + has_automatic_location: bool = Field(None, alias="has_automatic_location") + road_direction: Optional[Direction] = Field(None, alias="road_direction") road_names: Optional[list[str]] = Field(None, alias="road_names") name: Optional[str] = None description: Optional[str] = None - device_status: Optional[FieldDeviceStatus] = Field(None, alias="device_status") - update_date: Optional[datetime] = Field(None, alias="update_date") - has_automatic_location: Optional[bool] = Field(None, alias="has_automatic_location") - velocity_kph: Optional[float] = Field(None, alias="velocity_kph") + status_messages: Optional[list[str]] = Field(None, alias="status_messages") is_moving: Optional[bool] = Field(None, alias="is_moving") - road_direction: Optional[str] = Field(None, alias="road_direction") + road_event_ids: Optional[list[str]] = Field(None, alias="road_event_ids") + milepost: Optional[float] = None make: Optional[str] = None model: Optional[str] = None serial_number: Optional[str] = Field(None, alias="serial_number") firmware_version: Optional[str] = Field(None, alias="firmware_version") - milepost: Optional[float] = None \ No newline at end of file + velocity_kph: Optional[float] = Field(None, alias="velocity_kph") diff --git a/wzdx/models/field_device_feed/properties/__init__.py b/wzdx/models/field_device_feed/properties/__init__.py index 8629296..3a165f0 100644 --- a/wzdx/models/field_device_feed/properties/__init__.py +++ b/wzdx/models/field_device_feed/properties/__init__.py @@ -5,7 +5,7 @@ from .dynamic_message_sign import DynamicMessageSign from .flashing_beacon import FlashingBeacon, FlashingBeaconFunction from .hybrid_sign import HybridSign, HybridSignDynamicMessageFunction -from .marked_location import LocationMarker, MarkedLocation, MarkedLocationType +from .location_marker import LocationMarker, MarkedLocation, MarkedLocationType from .traffic_sensor import TrafficSensor, TrafficSensorLaneData from .traffic_signal import TrafficSignal, TrafficSignalMode @@ -26,4 +26,4 @@ "TrafficSensorLaneData", "TrafficSignal", "TrafficSignalMode", -] \ No newline at end of file +] diff --git a/wzdx/models/field_device_feed/properties/arrow_board.py b/wzdx/models/field_device_feed/properties/arrow_board.py index 7eaab6f..0ccd5b1 100644 --- a/wzdx/models/field_device_feed/properties/arrow_board.py +++ b/wzdx/models/field_device_feed/properties/arrow_board.py @@ -8,18 +8,21 @@ class ArrowBoardPattern(str, Enum): BLANK = "blank" RIGHT_ARROW_STATIC = "right-arrow-static" RIGHT_ARROW_FLASHING = "right-arrow-flashing" + RIGHT_ARROW_SEQUENTIAL = "right-arrow-sequential" RIGHT_CHEVRON_STATIC = "right-chevron-static" RIGHT_CHEVRON_FLASHING = "right-chevron-flashing" + RIGHT_CHEVRON_SEQUENTIAL = "right-chevron-sequential" LEFT_ARROW_STATIC = "left-arrow-static" LEFT_ARROW_FLASHING = "left-arrow-flashing" + LEFT_ARROW_SEQUENTIAL = "left-arrow-sequential" LEFT_CHEVRON_STATIC = "left-chevron-static" LEFT_CHEVRON_FLASHING = "left-chevron-flashing" + LEFT_CHEVRON_SEQUENTIAL = "left-chevron-sequential" BIDIRECTIONAL_ARROW_STATIC = "bidirectional-arrow-static" BIDIRECTIONAL_ARROW_FLASHING = "bidirectional-arrow-flashing" - FOUR_CORNERS_FLASHING = "four-corners-flashing" LINE_FLASHING = "line-flashing" DIAMONDS_ALTERNATING = "diamonds-alternating" - BAR_FLASHING = "bar-flashing" + FOUR_CORNERS_FLASHING = "four-corners-flashing" UNKNOWN = "unknown" class ArrowBoardCoreDetails(FieldDeviceCoreDetails): @@ -27,4 +30,4 @@ class ArrowBoardCoreDetails(FieldDeviceCoreDetails): class ArrowBoard(BaseModel): core_details: ArrowBoardCoreDetails = Field(alias="core_details") - pattern: Optional[ArrowBoardPattern] = None \ No newline at end of file + pattern: Optional[ArrowBoardPattern] = None diff --git a/wzdx/models/field_device_feed/properties/camera.py b/wzdx/models/field_device_feed/properties/camera.py index baef94a..f2586b2 100644 --- a/wzdx/models/field_device_feed/properties/camera.py +++ b/wzdx/models/field_device_feed/properties/camera.py @@ -1,12 +1,13 @@ # device_feed/properties/camera.py from typing import Literal, Optional +from datetime import datetime from pydantic import BaseModel, Field, HttpUrl from ..field_device_core_details import FieldDeviceCoreDetails class CameraCoreDetails(FieldDeviceCoreDetails): device_type: Literal["camera"] = "camera" - + class Camera(BaseModel): core_details: CameraCoreDetails = Field(alias="core_details") image_url: Optional[HttpUrl] = Field(None, alias="image_url") - image_timestamp: Optional[str] = Field(None, alias="image_timestamp") \ No newline at end of file + image_timestamp: Optional[datetime] = Field(None, alias="image_timestamp") diff --git a/wzdx/models/field_device_feed/properties/dynamic_message_sign.py b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py index d52d233..781d075 100644 --- a/wzdx/models/field_device_feed/properties/dynamic_message_sign.py +++ b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py @@ -1,11 +1,11 @@ # device_feed/properties/dynamic_message_sign.py -from typing import Literal, Optional +from typing import Literal from pydantic import BaseModel, Field from ..field_device_core_details import FieldDeviceCoreDetails class DynamicMessageSignCoreDetails(FieldDeviceCoreDetails): device_type: Literal["dynamic-message-sign"] = "dynamic-message-sign" - + class DynamicMessageSign(BaseModel): core_details: DynamicMessageSignCoreDetails = Field(alias="core_details") - message_multi_string: Optional[str] = Field(None, alias="message_multi_string") \ No newline at end of file + message_multi_string: str = Field(None, alias="message_multi_string") diff --git a/wzdx/models/field_device_feed/properties/field_device_properties.py b/wzdx/models/field_device_feed/properties/field_device_properties.py index 2dac960..1883368 100644 --- a/wzdx/models/field_device_feed/properties/field_device_properties.py +++ b/wzdx/models/field_device_feed/properties/field_device_properties.py @@ -1,5 +1,5 @@ # device_feed/properties/field_device_properties.py -from typing import Annotated, Union +from typing import Annotated from pydantic import Discriminator, Tag from .arrow_board import ArrowBoard @@ -7,7 +7,7 @@ from .dynamic_message_sign import DynamicMessageSign from .flashing_beacon import FlashingBeacon from .hybrid_sign import HybridSign -from .marked_location import MarkedLocation +from .location_marker import LocationMarker from .traffic_sensor import TrafficSensor from .traffic_signal import TrafficSignal @@ -19,13 +19,13 @@ def get_device_type(v): # Discriminated union based on core_details.device_type FieldDeviceProperties = Annotated[ - Annotated[ArrowBoard, Tag("arrow-board")] | - Annotated[Camera, Tag("camera")] | - Annotated[DynamicMessageSign, Tag("dynamic-message-sign")] | - Annotated[FlashingBeacon, Tag("flashing-beacon")] | - Annotated[HybridSign, Tag("hybrid-sign")] | - Annotated[MarkedLocation, Tag("location-marker")] | - Annotated[TrafficSensor, Tag("traffic-sensor")] | - Annotated[TrafficSignal, Tag("traffic-signal")], + Annotated[ArrowBoard, Tag("arrow-board")] + | Annotated[Camera, Tag("camera")] + | Annotated[DynamicMessageSign, Tag("dynamic-message-sign")] + | Annotated[FlashingBeacon, Tag("flashing-beacon")] + | Annotated[HybridSign, Tag("hybrid-sign")] + | Annotated[LocationMarker, Tag("location-marker")] + | Annotated[TrafficSensor, Tag("traffic-sensor")] + | Annotated[TrafficSignal, Tag("traffic-signal")], Discriminator(get_device_type), -] \ No newline at end of file +] diff --git a/wzdx/models/field_device_feed/properties/flashing_beacon.py b/wzdx/models/field_device_feed/properties/flashing_beacon.py index 02a7707..8c3d398 100644 --- a/wzdx/models/field_device_feed/properties/flashing_beacon.py +++ b/wzdx/models/field_device_feed/properties/flashing_beacon.py @@ -9,24 +9,12 @@ class FlashingBeaconFunction(str, Enum): QUEUE_WARNING = "queue-warning" REDUCED_SPEED = "reduced-speed" WORKERS_PRESENT = "workers-present" - FLAGGER_PRESENT = "flagger-present" - ROAD_WORK = "road-work" - UTILITY_WORK = "utility-work" - MAINTENANCE_WORK = "maintenance-work" - CONSTRUCTION = "construction" - INCIDENT = "incident" - EMERGENCY = "emergency" - CONGESTION = "congestion" - WEATHER = "weather" - SCHOOL_ZONE = "school-zone" - PEDESTRIAN_CROSSING = "pedestrian-crossing" - OTHER = "other" - UNKNOWN = "unknown" class FlashingBeaconCoreDetails(FieldDeviceCoreDetails): device_type: Literal["flashing-beacon"] = "flashing-beacon" class FlashingBeacon(BaseModel): core_details: FlashingBeaconCoreDetails = Field(alias="core_details") - function: Optional[FlashingBeaconFunction] = None - is_flashing: Optional[bool] = Field(None, alias="is_flashing") \ No newline at end of file + function: FlashingBeaconFunction = None + is_flashing: Optional[bool] = Field(None, alias="is_flashing") + sign_text: Optional[str] = Field(None, alias="sign_text") diff --git a/wzdx/models/field_device_feed/properties/hybrid_sign.py b/wzdx/models/field_device_feed/properties/hybrid_sign.py index c75c225..e8e1f02 100644 --- a/wzdx/models/field_device_feed/properties/hybrid_sign.py +++ b/wzdx/models/field_device_feed/properties/hybrid_sign.py @@ -8,7 +8,6 @@ class HybridSignDynamicMessageFunction(str, Enum): SPEED_LIMIT = "speed-limit" TRAVEL_TIME = "travel-time" OTHER = "other" - UNKNOWN = "unknown" class HybridSignCoreDetails(FieldDeviceCoreDetails): device_type: Literal["hybrid-sign"] = "hybrid-sign" @@ -18,4 +17,5 @@ class HybridSign(BaseModel): dynamic_message_function: Optional[HybridSignDynamicMessageFunction] = Field( None, alias="dynamic_message_function" ) - dynamic_message_text: Optional[str] = Field(None, alias="dynamic_message_text") \ No newline at end of file + dynamic_message_text: Optional[str] = Field(None, alias="dynamic_message_text") + static_sign_text: Optional[str] = Field(None, alias="static_sign_text") diff --git a/wzdx/models/field_device_feed/properties/marked_location.py b/wzdx/models/field_device_feed/properties/location_marker.py similarity index 85% rename from wzdx/models/field_device_feed/properties/marked_location.py rename to wzdx/models/field_device_feed/properties/location_marker.py index ce2e74c..bcf782c 100644 --- a/wzdx/models/field_device_feed/properties/marked_location.py +++ b/wzdx/models/field_device_feed/properties/location_marker.py @@ -6,6 +6,7 @@ class MarkedLocationType(str, Enum): AFAD = "afad" + DELINEATOR = "delineator" FLAGGER = "flagger" LANE_SHIFT = "lane-shift" LANE_CLOSURE = "lane-closure" @@ -18,13 +19,13 @@ class MarkedLocationType(str, Enum): WORK_ZONE_START = "work-zone-start" WORK_ZONE_END = "work-zone-end" -class LocationMarker(BaseModel): +class MarkedLocation(BaseModel): type: MarkedLocationType - description: Optional[str] = None + road_event_id: Optional[str] = None class MarkedLocationCoreDetails(FieldDeviceCoreDetails): device_type: Literal["location-marker"] = "location-marker" -class MarkedLocation(BaseModel): +class LocationMarker(BaseModel): core_details: MarkedLocationCoreDetails = Field(alias="core_details") - marked_locations: Optional[list[LocationMarker]] = Field(None, alias="marked_locations") \ No newline at end of file + marked_locations: Optional[list[MarkedLocation]] = Field(None, alias="marked_locations") \ No newline at end of file diff --git a/wzdx/models/field_device_feed/properties/traffic_sensor.py b/wzdx/models/field_device_feed/properties/traffic_sensor.py index 414afeb..6d7ef4e 100644 --- a/wzdx/models/field_device_feed/properties/traffic_sensor.py +++ b/wzdx/models/field_device_feed/properties/traffic_sensor.py @@ -5,8 +5,9 @@ class TrafficSensorLaneData(BaseModel): lane_order: int = Field(alias="lane_order") + road_event_id: Optional[str] = Field(None, alias="road_event_id") average_speed_kph: Optional[float] = Field(None, alias="average_speed_kph") - volume_vph: Optional[int] = Field(None, alias="volume_vph") + volume_vph: Optional[float] = Field(None, alias="volume_vph") occupancy_percent: Optional[float] = Field(None, alias="occupancy_percent") class TrafficSensorCoreDetails(FieldDeviceCoreDetails): @@ -14,10 +15,13 @@ class TrafficSensorCoreDetails(FieldDeviceCoreDetails): class TrafficSensor(BaseModel): core_details: TrafficSensorCoreDetails = Field(alias="core_details") - collection_interval_start_date: Optional[str] = Field( + collection_interval_start_date: str = Field( None, alias="collection_interval_start_date" ) - collection_interval_end_date: Optional[str] = Field( + collection_interval_end_date: str = Field( None, alias="collection_interval_end_date" ) - lane_data: Optional[list[TrafficSensorLaneData]] = Field(None, alias="lane_data") \ No newline at end of file + average_speed_kph: Optional[float] = Field(None, alias="average_speed_kph") + volume_vph: Optional[float] = Field(None, alias="volume_vph") + occupancy_percent: Optional[float] = Field(None, alias="occupancy_percent") + lane_data: Optional[list[TrafficSensorLaneData]] = Field(None, alias="lane_data") diff --git a/wzdx/models/field_device_feed/properties/traffic_signal.py b/wzdx/models/field_device_feed/properties/traffic_signal.py index 34ec371..6ca6936 100644 --- a/wzdx/models/field_device_feed/properties/traffic_signal.py +++ b/wzdx/models/field_device_feed/properties/traffic_signal.py @@ -1,5 +1,5 @@ # device_feed/properties/traffic_signal.py -from typing import Literal, Optional +from typing import Literal from pydantic import BaseModel, Field from enum import Enum from ..field_device_core_details import FieldDeviceCoreDetails @@ -19,4 +19,4 @@ class TrafficSignalCoreDetails(FieldDeviceCoreDetails): class TrafficSignal(BaseModel): core_details: TrafficSignalCoreDetails = Field(alias="core_details") - mode: Optional[TrafficSignalMode] = None \ No newline at end of file + mode: TrafficSignalMode = None \ No newline at end of file diff --git a/wzdx/raw_to_standard/icone.py b/wzdx/raw_to_standard/icone.py index cf316ec..84743e6 100644 --- a/wzdx/raw_to_standard/icone.py +++ b/wzdx/raw_to_standard/icone.py @@ -7,6 +7,11 @@ import xml.etree.ElementTree as ET from collections import OrderedDict +from pydantic import TypeAdapter + +from wzdx.models.field_device_feed.device_feed import DeviceFeed +from wzdx.models.field_device_feed.field_device_feature import FieldDeviceFeature + from ..tools import date_tools, geospatial_tools, wzdx_translator, combination from ..util.collections import PathDict @@ -55,56 +60,47 @@ def main(): ) -def generate_standard_messages_from_string(input_file_contents: str): +def generate_standard_messages_from_string(field_device_feed_json: str): """Generate RTDH standard messages from iCone XML string Args: input_file_contents: iCone XML string data """ - raw_messages = generate_raw_messages(input_file_contents) + device_feed = parse_device_feed(field_device_feed_json) standard_messages = [] - for message in raw_messages: - standard_messages.append( - generate_rtdh_standard_message_from_raw_single(message) - ) + for feature in device_feed.features: + standard_messages.append(create_rtdh_standard_msg(feature)) return standard_messages -def generate_raw_messages(message: str): - """Parse iCone XML string and return list of validated xml incidents +def retrieve_device_feed(auth_token: str, url: str) -> DeviceFeed: + """Retrieve Device Feed from URL with authentication token Args: - message: iCone XML string data + auth_token: Authentication token for accessing the device feed + url: URL of the device feed """ - response_xml = ET.fromstring(message) - msg_lst = response_xml.findall("incident") - messages = [] - - # Loop through all elements and print each element to PubSub - for msg in msg_lst: - incident = ET.tostring(msg, encoding="utf8") - obj = wzdx_translator.parse_xml_to_dict(incident) - if validate_incident(obj.get("incident", {})): - messages.append(incident) - else: - logging.warning("Invalid message") + import requests - return messages + headers = {"Authorization": f"Bearer {auth_token}"} + response = requests.get(url, headers=headers) + response.raise_for_status() + json_string = response.text + return parse_device_feed(json_string) -def generate_rtdh_standard_message_from_raw_single(raw_message_xml: str) -> dict: - """Generate RTDH standard message from iCone XML string - Args: - raw_message_xml: xml string iCone incident +def parse_device_feed(json_string: str) -> DeviceFeed: + """Parse iCone XML string and return list of validated xml incidents - Returns: - dict: RTDH standard message + Args: + message: iCone XML string data """ - obj = wzdx_translator.parse_xml_to_dict(raw_message_xml) - pd = PathDict(obj) - standard_message = create_rtdh_standard_msg(pd) - return standard_message + adapter = TypeAdapter(list[DeviceFeed]) + device_feed: list[DeviceFeed] = adapter.validate_json(json_string) + + if device_feed: + return device_feed[0] # parse script command line arguments diff --git a/wzdx/tools/date_tools.py b/wzdx/tools/date_tools.py index 3959976..ebe55e2 100644 --- a/wzdx/tools/date_tools.py +++ b/wzdx/tools/date_tools.py @@ -1,98 +1,98 @@ -import datetime as dt -import logging -from datetime import datetime, timedelta, timezone - -from dateutil import parser - -ISO_8601_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ" - - -def get_unix_from_iso_string(time_string): - return date_to_unix(parse_datetime_from_iso_string(time_string)) - - -def get_iso_string_from_unix(time_string): - return get_iso_string_from_datetime(parse_datetime_from_unix(time_string)) - - -def get_iso_string_from_datetime(date): - # This is added for unit test mocking (dt.datetime instead of just datetime) - if not date or type(date) != dt.datetime: - return None - return date.astimezone(timezone.utc).strftime(ISO_8601_FORMAT_STRING) - - -def parse_datetime_from_iso_string(time_string): - """Parse ISO string to datetime. Handles many different datetime formats""" - if not time_string or type(time_string) != str: - return None - - try: - return parser.parse(time_string) - except ValueError: - logging.warning("invalid datetime string: " + time_string) - return None - - -def parse_datetime_from_unix(time): - if not time: - return None - - if type(time) == str: - try: - return datetime_from_unix(float(time)) - except ValueError: - return None - elif type(time) == int or type(time) == float: - return datetime_from_unix(time) - - -def datetime_from_unix(time): - # I tested this method, and this value makes it fail (3001, 1, 19, 21, 59, 59) - if time > 32536850399: - return datetime.fromtimestamp(time / 1000, tz=timezone.utc) - else: - return datetime.fromtimestamp(time, tz=timezone.utc) - - -def date_to_unix(time: datetime): - if not time or type(time) != datetime: - return None - - return round(time.timestamp() * 1000) - - -# function to get event status from start and end datetimes -def get_event_status(start_time, end_time): - if not start_time or type(start_time) != dt.datetime: - return None - - event_status = "active" - - current_time = datetime.now() - - # check if datetime is time zone aware. If it is, get utc time - if ( - start_time.tzinfo is not None - and start_time.tzinfo.utcoffset(start_time) is not None - ): - current_time = datetime.now(timezone.utc) - - future_date_after_2weeks = current_time + timedelta(days=14) - past_date_2weeks_ago = current_time - timedelta(days=14) - - if current_time < start_time: - if start_time < future_date_after_2weeks: - event_status = "pending" - else: - event_status = "planned" - elif end_time and type(end_time) == dt.datetime and end_time < current_time: - if end_time > past_date_2weeks_ago: - event_status = "completed_recently" - else: - event_status = "completed" - return event_status - - -def get_current_ts_millis(): - return datetime.now(timezone.utc).timestamp() +import datetime as dt +import logging +from datetime import datetime, timedelta, timezone + +from dateutil import parser + +ISO_8601_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ" + + +def get_unix_from_iso_string(time_string): + return date_to_unix(parse_datetime_from_iso_string(time_string)) + + +def get_iso_string_from_unix(time_string): + return get_iso_string_from_datetime(parse_datetime_from_unix(time_string)) + + +def get_iso_string_from_datetime(date): + # This is added for unit test mocking (dt.datetime instead of just datetime) + if not date or type(date) is not dt.datetime: + return None + return date.astimezone(timezone.utc).strftime(ISO_8601_FORMAT_STRING) + + +def parse_datetime_from_iso_string(time_string): + """Parse ISO string to datetime. Handles many different datetime formats""" + if not time_string or type(time_string) is not str: + return None + + try: + return parser.parse(time_string) + except ValueError: + logging.warning("invalid datetime string: " + time_string) + return None + + +def parse_datetime_from_unix(time): + if not time: + return None + + if type(time) is str: + try: + return datetime_from_unix(float(time)) + except ValueError: + return None + elif type(time) is int or type(time) is float: + return datetime_from_unix(time) + + +def datetime_from_unix(time): + # I tested this method, and this value makes it fail (3001, 1, 19, 21, 59, 59) + if time > 32536850399: + return datetime.fromtimestamp(time / 1000, tz=timezone.utc) + else: + return datetime.fromtimestamp(time, tz=timezone.utc) + + +def date_to_unix(time: datetime): + if not time or type(time) is not datetime: + return None + + return round(time.timestamp() * 1000) + + +# function to get event status from start and end datetimes +def get_event_status(start_time, end_time): + if not start_time or type(start_time) is not dt.datetime: + return None + + event_status = "active" + + current_time = datetime.now() + + # check if datetime is time zone aware. If it is, get utc time + if ( + start_time.tzinfo is not None + and start_time.tzinfo.utcoffset(start_time) is not None + ): + current_time = datetime.now(timezone.utc) + + future_date_after_2weeks = current_time + timedelta(days=14) + past_date_2weeks_ago = current_time - timedelta(days=14) + + if current_time < start_time: + if start_time < future_date_after_2weeks: + event_status = "pending" + else: + event_status = "planned" + elif end_time and type(end_time) is dt.datetime and end_time < current_time: + if end_time > past_date_2weeks_ago: + event_status = "completed_recently" + else: + event_status = "completed" + return event_status + + +def get_current_ts_millis(): + return datetime.now(timezone.utc).timestamp() From c279b8d36f4914fe9dac511a6cdd048e5dd3b1b4 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 30 Dec 2025 11:57:46 -0700 Subject: [PATCH 04/14] Tweaking model definition defaults --- README.md | 14 ++-- tests/models/field_device_feed_test.py | 65 ++++++++++++++++++- .../field_device_core_details.py | 21 +++--- .../field_device_feed/field_device_feature.py | 3 +- .../field_device_feed/field_device_type.py | 21 +++--- wzdx/tools/cdot_geospatial_api.py | 2 +- 6 files changed, 96 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 8504095..7cbbace 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,13 @@ Please set up the following environment variable for your local computer before Runtime Environment Variables: -| Name | Value | Description | -| :--------------------------- | :---------------------------------------------------------------------------------------------------: | ---------------------------------------------------: | -| contact_name | Heather Pickering-Hilgers | name of WZDx feed contact | -| contact_email | heather.pickeringhilgers@state.co.us | email of WZDx feed contact | -| publisher | CDOT | name of the organization issuing the WZDx feed | -| CDOT_GEOSPATIAL_API_BASE_URL | https://dtdapps.codot.gov/server/rest/services/LRS/Routes_withDEC/MapServer/exts/CdotLrsAccessRounded | GIS server endpoint used for geospatial api | -| NAMESPACE_UUID | 00000000-0000-0000-0000-000000000000 | UUID used to pseudo-randomly tag all UUIDs generated | +| Name | Value | Description | +| :--------------------------- | :-----------------------------------------------------------------------------------------------: | ---------------------------------------------------: | +| contact_name | Heather Pickering-Hilgers | name of WZDx feed contact | +| contact_email | heather.pickeringhilgers@state.co.us | email of WZDx feed contact | +| publisher | CDOT | name of the organization issuing the WZDx feed | +| CDOT_GEOSPATIAL_API_BASE_URL | https://dtdapps.codot.gov/server/rest/services/LRS/Routes_withDEC/MapServer/exts/LrsServerRounded | GIS server endpoint used for geospatial api | +| NAMESPACE_UUID | 00000000-0000-0000-0000-000000000000 | UUID used to pseudo-randomly tag all UUIDs generated | Example usage: for mac computer run the following script to initialize the environment variable: diff --git a/tests/models/field_device_feed_test.py b/tests/models/field_device_feed_test.py index fcc14e1..2f560a7 100644 --- a/tests/models/field_device_feed_test.py +++ b/tests/models/field_device_feed_test.py @@ -1,6 +1,7 @@ from pydantic import TypeAdapter from wzdx.models.field_device_feed.device_feed import DeviceFeed + def test_deserialization(): # Deserialize from JSON string json_string = """ @@ -117,4 +118,66 @@ def test_deserialization(): ) print("JSON Output", json_output) - assert False + + expected_object = { + "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"}], + }, + }, + ], + } + + assert ( + device_feed_list[0].model_dump(by_alias=True, exclude_none=True, mode="json") + == expected_object + ) diff --git a/wzdx/models/field_device_feed/field_device_core_details.py b/wzdx/models/field_device_feed/field_device_core_details.py index 294dc41..8dc878f 100644 --- a/wzdx/models/field_device_feed/field_device_core_details.py +++ b/wzdx/models/field_device_feed/field_device_core_details.py @@ -6,22 +6,23 @@ from .field_device_status import FieldDeviceStatus from ..enums import Direction + class FieldDeviceCoreDetails(BaseModel): - device_type: FieldDeviceType = Field(None, alias="device_type") - data_source_id: str = Field(None, alias="data_source_id") - device_status: FieldDeviceStatus = Field(None, alias="device_status") - update_date: datetime = Field(None, alias="update_date") - has_automatic_location: bool = Field(None, alias="has_automatic_location") + device_type: FieldDeviceType = Field(alias="device_type") + data_source_id: str = Field(alias="data_source_id") + device_status: FieldDeviceStatus = Field(alias="device_status") + update_date: datetime = Field(alias="update_date") + has_automatic_location: bool = Field(alias="has_automatic_location") road_direction: Optional[Direction] = Field(None, alias="road_direction") road_names: Optional[list[str]] = Field(None, alias="road_names") - name: Optional[str] = None - description: Optional[str] = None + name: Optional[str] = Field(None, alias="name") + description: Optional[str] = Field(None, alias="description") status_messages: Optional[list[str]] = Field(None, alias="status_messages") is_moving: Optional[bool] = Field(None, alias="is_moving") road_event_ids: Optional[list[str]] = Field(None, alias="road_event_ids") - milepost: Optional[float] = None - make: Optional[str] = None - model: Optional[str] = None + milepost: Optional[float] = Field(None, alias="milepost") + make: Optional[str] = Field(None, alias="make") + model: Optional[str] = Field(None, alias="model") serial_number: Optional[str] = Field(None, alias="serial_number") firmware_version: Optional[str] = Field(None, alias="firmware_version") velocity_kph: Optional[float] = Field(None, alias="velocity_kph") diff --git a/wzdx/models/field_device_feed/field_device_feature.py b/wzdx/models/field_device_feed/field_device_feature.py index 47ba2a3..6b44cd3 100644 --- a/wzdx/models/field_device_feed/field_device_feature.py +++ b/wzdx/models/field_device_feed/field_device_feature.py @@ -5,9 +5,10 @@ from ..geometry.geojson_geometry import GeoJsonGeometry from .properties.field_device_properties import FieldDeviceProperties + class FieldDeviceFeature(BaseModel): id: str type: str properties: FieldDeviceProperties geometry: GeoJsonGeometry # GeoJSON geometry object - bbox: Optional[list[float]] = None \ No newline at end of file + bbox: Optional[list[float]] = None diff --git a/wzdx/models/field_device_feed/field_device_type.py b/wzdx/models/field_device_feed/field_device_type.py index 8c86d85..1eae56c 100644 --- a/wzdx/models/field_device_feed/field_device_type.py +++ b/wzdx/models/field_device_feed/field_device_type.py @@ -1,12 +1,13 @@ # device_feed/field_device_type.py -from enum import Enum +from typing_extensions import Literal -class FieldDeviceType(str, Enum): - ARROW_BOARD = "arrow-board" - CAMERA = "camera" - DYNAMIC_MESSAGE_SIGN = "dynamic-message-sign" - FLASHING_BEACON = "flashing-beacon" - HYBRID_SIGN = "hybrid-sign" - LOCATION_MARKER = "location-marker" - TRAFFIC_SENSOR = "traffic-sensor" - TRAFFIC_SIGNAL = "traffic-signal" \ No newline at end of file +FieldDeviceType = Literal[ + "arrow-board", + "camera", + "dynamic-message-sign", + "flashing-beacon", + "hybrid-sign", + "location-marker", + "traffic-sensor", + "traffic-signal", +] diff --git a/wzdx/tools/cdot_geospatial_api.py b/wzdx/tools/cdot_geospatial_api.py index 02fbb8c..c181a81 100644 --- a/wzdx/tools/cdot_geospatial_api.py +++ b/wzdx/tools/cdot_geospatial_api.py @@ -18,7 +18,7 @@ def __init__( setCachedRequest: Callable[[str, str], None] = lambda url, response: None, BASE_URL: str = os.getenv( "CDOT_GEOSPATIAL_API_BASE_URL", - "https://dtdapps.coloradodot.info/arcgis/rest/services/LRS/Routes/MapServer/exts/LrsServerRounded", + "https://dtdapps.codot.gov/server/rest/services/LRS/Routes_withDEC/MapServer/exts/LrsServerRounded", ), ): """Initialize the Geospatial API From dff3bf5632b34226e58c2af932c9c6a596bbf3c9 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 30 Dec 2025 12:02:51 -0700 Subject: [PATCH 05/14] Reverting icone raw to standard changes --- wzdx/raw_to_standard/icone.py | 60 ++++++------ .../sample_files/raw/icone/field_devices.json | 94 ------------------- 2 files changed, 32 insertions(+), 122 deletions(-) delete mode 100644 wzdx/sample_files/raw/icone/field_devices.json diff --git a/wzdx/raw_to_standard/icone.py b/wzdx/raw_to_standard/icone.py index 84743e6..cf316ec 100644 --- a/wzdx/raw_to_standard/icone.py +++ b/wzdx/raw_to_standard/icone.py @@ -7,11 +7,6 @@ import xml.etree.ElementTree as ET from collections import OrderedDict -from pydantic import TypeAdapter - -from wzdx.models.field_device_feed.device_feed import DeviceFeed -from wzdx.models.field_device_feed.field_device_feature import FieldDeviceFeature - from ..tools import date_tools, geospatial_tools, wzdx_translator, combination from ..util.collections import PathDict @@ -60,47 +55,56 @@ def main(): ) -def generate_standard_messages_from_string(field_device_feed_json: str): +def generate_standard_messages_from_string(input_file_contents: str): """Generate RTDH standard messages from iCone XML string Args: input_file_contents: iCone XML string data """ - device_feed = parse_device_feed(field_device_feed_json) + raw_messages = generate_raw_messages(input_file_contents) standard_messages = [] - for feature in device_feed.features: - standard_messages.append(create_rtdh_standard_msg(feature)) + for message in raw_messages: + standard_messages.append( + generate_rtdh_standard_message_from_raw_single(message) + ) return standard_messages -def retrieve_device_feed(auth_token: str, url: str) -> DeviceFeed: - """Retrieve Device Feed from URL with authentication token +def generate_raw_messages(message: str): + """Parse iCone XML string and return list of validated xml incidents Args: - auth_token: Authentication token for accessing the device feed - url: URL of the device feed + message: iCone XML string data """ - import requests - - headers = {"Authorization": f"Bearer {auth_token}"} - response = requests.get(url, headers=headers) - response.raise_for_status() - json_string = response.text + response_xml = ET.fromstring(message) + msg_lst = response_xml.findall("incident") + messages = [] + + # Loop through all elements and print each element to PubSub + for msg in msg_lst: + incident = ET.tostring(msg, encoding="utf8") + obj = wzdx_translator.parse_xml_to_dict(incident) + if validate_incident(obj.get("incident", {})): + messages.append(incident) + else: + logging.warning("Invalid message") - return parse_device_feed(json_string) + return messages -def parse_device_feed(json_string: str) -> DeviceFeed: - """Parse iCone XML string and return list of validated xml incidents +def generate_rtdh_standard_message_from_raw_single(raw_message_xml: str) -> dict: + """Generate RTDH standard message from iCone XML string Args: - message: iCone XML string data - """ - adapter = TypeAdapter(list[DeviceFeed]) - device_feed: list[DeviceFeed] = adapter.validate_json(json_string) + raw_message_xml: xml string iCone incident - if device_feed: - return device_feed[0] + Returns: + dict: RTDH standard message + """ + obj = wzdx_translator.parse_xml_to_dict(raw_message_xml) + pd = PathDict(obj) + standard_message = create_rtdh_standard_msg(pd) + return standard_message # parse script command line arguments diff --git a/wzdx/sample_files/raw/icone/field_devices.json b/wzdx/sample_files/raw/icone/field_devices.json deleted file mode 100644 index 194ffa8..0000000 --- a/wzdx/sample_files/raw/icone/field_devices.json +++ /dev/null @@ -1,94 +0,0 @@ -[ - { - "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" - } - } - } - } - ] - } -] \ No newline at end of file From 38169f5d662948b5806d49af8abb663026d6de68 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Mon, 5 Jan 2026 18:11:52 -0700 Subject: [PATCH 06/14] Cleaning up comments and test data --- .../models/field_device_feed_icone_final.json | 57 +++++++ .../models/field_device_feed_icone_raw.json | 94 +++++++++++ tests/models/field_device_feed_test.py | 159 +----------------- wzdx/models/feed_info/__init__.py | 3 +- wzdx/models/feed_info/feed_data_source.py | 1 - wzdx/models/feed_info/feed_info.py | 5 - wzdx/models/field_device_feed/__init__.py | 3 +- wzdx/models/field_device_feed/device_feed.py | 5 +- .../field_device_feed/field_device_feature.py | 3 +- .../field_device_feed/field_device_status.py | 3 +- .../field_device_feed/field_device_type.py | 1 - .../field_device_feed/properties/__init__.py | 1 - .../properties/arrow_board.py | 1 - .../field_device_feed/properties/camera.py | 1 - .../properties/dynamic_message_sign.py | 1 - .../properties/field_device_properties.py | 1 - .../properties/flashing_beacon.py | 1 - .../properties/hybrid_sign.py | 1 - .../properties/location_marker.py | 3 +- .../properties/traffic_sensor.py | 1 - .../properties/traffic_signal.py | 3 +- wzdx/models/geometry/__init__.py | 3 +- wzdx/models/geometry/geojson_geometry.py | 3 +- wzdx/models/geometry/geojson_linestring.py | 3 +- wzdx/models/geometry/geojson_multipoint.py | 3 +- wzdx/models/geometry/geojson_point.py | 3 +- wzdx/models/geometry/geojson_polygon.py | 3 +- 27 files changed, 169 insertions(+), 197 deletions(-) create mode 100644 tests/data/models/field_device_feed_icone_final.json create mode 100644 tests/data/models/field_device_feed_icone_raw.json diff --git a/tests/data/models/field_device_feed_icone_final.json b/tests/data/models/field_device_feed_icone_final.json new file mode 100644 index 0000000..d24acc0 --- /dev/null +++ b/tests/data/models/field_device_feed_icone_final.json @@ -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"}] + } + } + ] + } \ No newline at end of file diff --git a/tests/data/models/field_device_feed_icone_raw.json b/tests/data/models/field_device_feed_icone_raw.json new file mode 100644 index 0000000..cfd2229 --- /dev/null +++ b/tests/data/models/field_device_feed_icone_raw.json @@ -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" + } + } + } + } + ] + } + ] \ No newline at end of file diff --git a/tests/models/field_device_feed_test.py b/tests/models/field_device_feed_test.py index 2f560a7..ae27b20 100644 --- a/tests/models/field_device_feed_test.py +++ b/tests/models/field_device_feed_test.py @@ -1,105 +1,10 @@ from pydantic import TypeAdapter from wzdx.models.field_device_feed.device_feed import DeviceFeed - +import json def test_deserialization(): # Deserialize from JSON string - json_string = """ - [ - { - "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" - } - } - } - } - ] - } - ] - """ + json_string = open("./tests/data/models/field_device_feed_icone_raw.json").read() adapter = TypeAdapter(list[DeviceFeed]) device_feed_list: list[DeviceFeed] = adapter.validate_json(json_string) @@ -119,63 +24,9 @@ def test_deserialization(): print("JSON Output", json_output) - expected_object = { - "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"}], - }, - }, - ], - } + expected_object = json.load( + open("./tests/data/models/field_device_feed_icone_final.json") + ) assert ( device_feed_list[0].model_dump(by_alias=True, exclude_none=True, mode="json") diff --git a/wzdx/models/feed_info/__init__.py b/wzdx/models/feed_info/__init__.py index c84db87..75e046d 100644 --- a/wzdx/models/feed_info/__init__.py +++ b/wzdx/models/feed_info/__init__.py @@ -1,8 +1,7 @@ -# wzdx/__init__.py from .feed_info import FeedInfo from .feed_data_source import FeedDataSource __all__ = [ "FeedInfo", "FeedDataSource" -] \ No newline at end of file +] diff --git a/wzdx/models/feed_info/feed_data_source.py b/wzdx/models/feed_info/feed_data_source.py index 5ee3862..0f1314a 100644 --- a/wzdx/models/feed_info/feed_data_source.py +++ b/wzdx/models/feed_info/feed_data_source.py @@ -1,4 +1,3 @@ -# wzdx/feed_data_source.py from typing import Optional from datetime import datetime from pydantic import BaseModel, Field diff --git a/wzdx/models/feed_info/feed_info.py b/wzdx/models/feed_info/feed_info.py index f84c56c..47a4dbb 100644 --- a/wzdx/models/feed_info/feed_info.py +++ b/wzdx/models/feed_info/feed_info.py @@ -1,13 +1,8 @@ -# wzdx/feed_info.py from typing import Optional from pydantic import BaseModel, Field, EmailStr from .feed_data_source import FeedDataSource from datetime import datetime -class FeedInfoIconeCustom(BaseModel): - """Custom iCone properties""" - # Add iCone custom fields here based on FeedInfoIconeCustom.java - pass class FeedInfo(BaseModel): """ diff --git a/wzdx/models/field_device_feed/__init__.py b/wzdx/models/field_device_feed/__init__.py index a187d0b..4c6b6df 100644 --- a/wzdx/models/field_device_feed/__init__.py +++ b/wzdx/models/field_device_feed/__init__.py @@ -1,4 +1,3 @@ -# device_feed/__init__.py from .device_feed import DeviceFeed from .field_device_feature import FieldDeviceFeature from .field_device_core_details import FieldDeviceCoreDetails @@ -45,4 +44,4 @@ "TrafficSensorLaneData", "TrafficSignal", "TrafficSignalMode", -] \ No newline at end of file +] diff --git a/wzdx/models/field_device_feed/device_feed.py b/wzdx/models/field_device_feed/device_feed.py index b7f1eb5..aea3fe3 100644 --- a/wzdx/models/field_device_feed/device_feed.py +++ b/wzdx/models/field_device_feed/device_feed.py @@ -1,4 +1,3 @@ -# device_feed/device_feed.py from typing import Optional from pydantic import BaseModel, Field @@ -6,11 +5,9 @@ from .field_device_feature import FieldDeviceFeature -# Note: You'll need to implement FeedInfo from the wzdx package -# from ..wzdx import FeedInfo class DeviceFeed(BaseModel): feed_info: FeedInfo = Field(alias="feed_info") type: str features: list[FieldDeviceFeature] - bbox: Optional[list[float]] = None \ No newline at end of file + bbox: Optional[list[float]] = None diff --git a/wzdx/models/field_device_feed/field_device_feature.py b/wzdx/models/field_device_feed/field_device_feature.py index 6b44cd3..89eca1a 100644 --- a/wzdx/models/field_device_feed/field_device_feature.py +++ b/wzdx/models/field_device_feed/field_device_feature.py @@ -1,4 +1,3 @@ -# device_feed/field_device_feature.py from typing import Optional from pydantic import BaseModel @@ -10,5 +9,5 @@ class FieldDeviceFeature(BaseModel): id: str type: str properties: FieldDeviceProperties - geometry: GeoJsonGeometry # GeoJSON geometry object + geometry: GeoJsonGeometry bbox: Optional[list[float]] = None diff --git a/wzdx/models/field_device_feed/field_device_status.py b/wzdx/models/field_device_feed/field_device_status.py index 03a6cc3..67a67d2 100644 --- a/wzdx/models/field_device_feed/field_device_status.py +++ b/wzdx/models/field_device_feed/field_device_status.py @@ -1,8 +1,7 @@ -# device_feed/field_device_status.py from enum import Enum class FieldDeviceStatus(str, Enum): ERROR = "error" OK = "ok" UNKNOWN = "unknown" - WARNING = "warning" \ No newline at end of file + WARNING = "warning" diff --git a/wzdx/models/field_device_feed/field_device_type.py b/wzdx/models/field_device_feed/field_device_type.py index 1eae56c..53b5aca 100644 --- a/wzdx/models/field_device_feed/field_device_type.py +++ b/wzdx/models/field_device_feed/field_device_type.py @@ -1,4 +1,3 @@ -# device_feed/field_device_type.py from typing_extensions import Literal FieldDeviceType = Literal[ diff --git a/wzdx/models/field_device_feed/properties/__init__.py b/wzdx/models/field_device_feed/properties/__init__.py index 3a165f0..7f4e09f 100644 --- a/wzdx/models/field_device_feed/properties/__init__.py +++ b/wzdx/models/field_device_feed/properties/__init__.py @@ -1,4 +1,3 @@ -# device_feed/properties/__init__.py from .field_device_properties import FieldDeviceProperties from .arrow_board import ArrowBoard, ArrowBoardPattern from .camera import Camera diff --git a/wzdx/models/field_device_feed/properties/arrow_board.py b/wzdx/models/field_device_feed/properties/arrow_board.py index 0ccd5b1..e3021ff 100644 --- a/wzdx/models/field_device_feed/properties/arrow_board.py +++ b/wzdx/models/field_device_feed/properties/arrow_board.py @@ -1,4 +1,3 @@ -# device_feed/properties/arrow_board.py from typing import Literal, Optional from pydantic import BaseModel, Field from enum import Enum diff --git a/wzdx/models/field_device_feed/properties/camera.py b/wzdx/models/field_device_feed/properties/camera.py index f2586b2..547b0b5 100644 --- a/wzdx/models/field_device_feed/properties/camera.py +++ b/wzdx/models/field_device_feed/properties/camera.py @@ -1,4 +1,3 @@ -# device_feed/properties/camera.py from typing import Literal, Optional from datetime import datetime from pydantic import BaseModel, Field, HttpUrl diff --git a/wzdx/models/field_device_feed/properties/dynamic_message_sign.py b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py index 781d075..f5bbe68 100644 --- a/wzdx/models/field_device_feed/properties/dynamic_message_sign.py +++ b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py @@ -1,4 +1,3 @@ -# device_feed/properties/dynamic_message_sign.py from typing import Literal from pydantic import BaseModel, Field from ..field_device_core_details import FieldDeviceCoreDetails diff --git a/wzdx/models/field_device_feed/properties/field_device_properties.py b/wzdx/models/field_device_feed/properties/field_device_properties.py index 1883368..8b20079 100644 --- a/wzdx/models/field_device_feed/properties/field_device_properties.py +++ b/wzdx/models/field_device_feed/properties/field_device_properties.py @@ -1,4 +1,3 @@ -# device_feed/properties/field_device_properties.py from typing import Annotated from pydantic import Discriminator, Tag diff --git a/wzdx/models/field_device_feed/properties/flashing_beacon.py b/wzdx/models/field_device_feed/properties/flashing_beacon.py index 8c3d398..96cbea9 100644 --- a/wzdx/models/field_device_feed/properties/flashing_beacon.py +++ b/wzdx/models/field_device_feed/properties/flashing_beacon.py @@ -1,4 +1,3 @@ -# device_feed/properties/flashing_beacon.py from typing import Literal, Optional from pydantic import BaseModel, Field from enum import Enum diff --git a/wzdx/models/field_device_feed/properties/hybrid_sign.py b/wzdx/models/field_device_feed/properties/hybrid_sign.py index e8e1f02..eeedb5d 100644 --- a/wzdx/models/field_device_feed/properties/hybrid_sign.py +++ b/wzdx/models/field_device_feed/properties/hybrid_sign.py @@ -1,4 +1,3 @@ -# device_feed/properties/hybrid_sign.py from typing import Literal, Optional from pydantic import BaseModel, Field from enum import Enum diff --git a/wzdx/models/field_device_feed/properties/location_marker.py b/wzdx/models/field_device_feed/properties/location_marker.py index bcf782c..6cc80cb 100644 --- a/wzdx/models/field_device_feed/properties/location_marker.py +++ b/wzdx/models/field_device_feed/properties/location_marker.py @@ -1,4 +1,3 @@ -# device_feed/properties/marked_location.py from typing import Literal, Optional from pydantic import BaseModel, Field from enum import Enum @@ -28,4 +27,4 @@ class MarkedLocationCoreDetails(FieldDeviceCoreDetails): class LocationMarker(BaseModel): core_details: MarkedLocationCoreDetails = Field(alias="core_details") - marked_locations: Optional[list[MarkedLocation]] = Field(None, alias="marked_locations") \ No newline at end of file + marked_locations: Optional[list[MarkedLocation]] = Field(None, alias="marked_locations") diff --git a/wzdx/models/field_device_feed/properties/traffic_sensor.py b/wzdx/models/field_device_feed/properties/traffic_sensor.py index 6d7ef4e..dfe3cc4 100644 --- a/wzdx/models/field_device_feed/properties/traffic_sensor.py +++ b/wzdx/models/field_device_feed/properties/traffic_sensor.py @@ -1,4 +1,3 @@ -# device_feed/properties/traffic_sensor.py from typing import Literal, Optional from pydantic import BaseModel, Field from ..field_device_core_details import FieldDeviceCoreDetails diff --git a/wzdx/models/field_device_feed/properties/traffic_signal.py b/wzdx/models/field_device_feed/properties/traffic_signal.py index 6ca6936..d732a8c 100644 --- a/wzdx/models/field_device_feed/properties/traffic_signal.py +++ b/wzdx/models/field_device_feed/properties/traffic_signal.py @@ -1,4 +1,3 @@ -# device_feed/properties/traffic_signal.py from typing import Literal from pydantic import BaseModel, Field from enum import Enum @@ -19,4 +18,4 @@ class TrafficSignalCoreDetails(FieldDeviceCoreDetails): class TrafficSignal(BaseModel): core_details: TrafficSignalCoreDetails = Field(alias="core_details") - mode: TrafficSignalMode = None \ No newline at end of file + mode: TrafficSignalMode = None diff --git a/wzdx/models/geometry/__init__.py b/wzdx/models/geometry/__init__.py index 160b3d6..a739838 100644 --- a/wzdx/models/geometry/__init__.py +++ b/wzdx/models/geometry/__init__.py @@ -1,4 +1,3 @@ -# wzdx/geometry/__init__.py from .geojson_geometry import GeoJsonGeometry from .geojson_point import GeoJsonPoint from .geojson_linestring import GeoJsonLineString @@ -11,4 +10,4 @@ "GeoJsonLineString", "GeoJsonMultiPoint", "GeoJsonPolygon", -] \ No newline at end of file +] diff --git a/wzdx/models/geometry/geojson_geometry.py b/wzdx/models/geometry/geojson_geometry.py index 419735c..e6922b8 100644 --- a/wzdx/models/geometry/geojson_geometry.py +++ b/wzdx/models/geometry/geojson_geometry.py @@ -1,4 +1,3 @@ -# wzdx/geometry/geojson_geometry.py from typing import Annotated, Union from pydantic import Field @@ -15,4 +14,4 @@ GeoJsonPolygon, ], Field(discriminator="type"), -] \ No newline at end of file +] diff --git a/wzdx/models/geometry/geojson_linestring.py b/wzdx/models/geometry/geojson_linestring.py index 0a47647..1bdde39 100644 --- a/wzdx/models/geometry/geojson_linestring.py +++ b/wzdx/models/geometry/geojson_linestring.py @@ -1,8 +1,7 @@ -# wzdx/geometry/geojson_linestring.py from typing import Literal from pydantic import BaseModel class GeoJsonLineString(BaseModel): """GeoJSON LineString geometry""" type: Literal["LineString"] - coordinates: list[list[float]] # Array of positions \ No newline at end of file + coordinates: list[list[float]] # Array of positions diff --git a/wzdx/models/geometry/geojson_multipoint.py b/wzdx/models/geometry/geojson_multipoint.py index 8d33c5f..1e76f1a 100644 --- a/wzdx/models/geometry/geojson_multipoint.py +++ b/wzdx/models/geometry/geojson_multipoint.py @@ -1,8 +1,7 @@ -# wzdx/geometry/geojson_multipoint.py from typing import Literal from pydantic import BaseModel class GeoJsonMultiPoint(BaseModel): """GeoJSON MultiPoint geometry""" type: Literal["MultiPoint"] - coordinates: list[list[float]] # Array of positions \ No newline at end of file + coordinates: list[list[float]] # Array of positions diff --git a/wzdx/models/geometry/geojson_point.py b/wzdx/models/geometry/geojson_point.py index b4cdfcb..84cd498 100644 --- a/wzdx/models/geometry/geojson_point.py +++ b/wzdx/models/geometry/geojson_point.py @@ -1,8 +1,7 @@ -# wzdx/geometry/geojson_point.py from typing import Literal from pydantic import BaseModel class GeoJsonPoint(BaseModel): """GeoJSON Point geometry""" type: Literal["Point"] - coordinates: list[float] # [longitude, latitude] or [longitude, latitude, elevation] \ No newline at end of file + coordinates: list[float] # [longitude, latitude] or [longitude, latitude, elevation] diff --git a/wzdx/models/geometry/geojson_polygon.py b/wzdx/models/geometry/geojson_polygon.py index 5a0810a..214c16c 100644 --- a/wzdx/models/geometry/geojson_polygon.py +++ b/wzdx/models/geometry/geojson_polygon.py @@ -1,8 +1,7 @@ -# wzdx/geometry/geojson_polygon.py from typing import Literal from pydantic import BaseModel class GeoJsonPolygon(BaseModel): """GeoJSON Polygon geometry""" type: Literal["Polygon"] - coordinates: list[list[list[float]]] # Array of linear rings \ No newline at end of file + coordinates: list[list[list[float]]] # Array of linear rings From df6b96f71d739218c5eeb5bd7935009c1e515547 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Mon, 5 Jan 2026 18:27:33 -0700 Subject: [PATCH 07/14] Adding tests for datetime_from_unix --- tests/tools/date_tools_test.py | 22 ++++++++++++++++++++++ wzdx/tools/date_tools.py | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/tools/date_tools_test.py b/tests/tools/date_tools_test.py index 26a600f..bf5aac9 100644 --- a/tests/tools/date_tools_test.py +++ b/tests/tools/date_tools_test.py @@ -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" diff --git a/wzdx/tools/date_tools.py b/wzdx/tools/date_tools.py index ebe55e2..edc747b 100644 --- a/wzdx/tools/date_tools.py +++ b/wzdx/tools/date_tools.py @@ -48,8 +48,8 @@ def parse_datetime_from_unix(time): def datetime_from_unix(time): - # I tested this method, and this value makes it fail (3001, 1, 19, 21, 59, 59) - if time > 32536850399: + # Maximum unix value of 32536850400 due to windows 32-bit signed integer (max valid date is 3001, 1, 19, 21, 59, 59) + if time >= 32536850400: return datetime.fromtimestamp(time / 1000, tz=timezone.utc) else: return datetime.fromtimestamp(time, tz=timezone.utc) From e8dcde5d2a0ab679d4733090bff654a004a548e5 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 6 Jan 2026 14:17:32 -0700 Subject: [PATCH 08/14] Re-generating requirements.txt with pydantic --- requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/requirements.txt b/requirements.txt index fdb311b..51dc413 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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" @@ -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" @@ -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" @@ -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" From 27591b4b0f216fdfa280af5c81a45c988621d82b Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 6 Jan 2026 17:25:52 -0700 Subject: [PATCH 09/14] Adding descriptions and standardizing docstring comments --- wzdx/models/feed_info/feed_data_source.py | 42 ++++++-- wzdx/models/feed_info/feed_info.py | 51 +++++++-- wzdx/models/field_device_feed/device_feed.py | 27 ++++- .../field_device_core_details.py | 100 ++++++++++++++---- .../field_device_feed/field_device_feature.py | 36 +++++-- .../field_device_feed/field_device_status.py | 15 ++- .../field_device_feed/field_device_type.py | 5 + .../properties/arrow_board.py | 76 +++++++++---- .../field_device_feed/properties/camera.py | 33 +++++- .../properties/dynamic_message_sign.py | 20 +++- .../properties/field_device_properties.py | 21 +++- .../properties/flashing_beacon.py | 49 +++++++-- .../properties/hybrid_sign.py | 45 ++++++-- .../properties/location_marker.py | 73 ++++++++++--- .../properties/traffic_sensor.py | 83 ++++++++++++--- .../properties/traffic_signal.py | 44 ++++++-- 16 files changed, 587 insertions(+), 133 deletions(-) diff --git a/wzdx/models/feed_info/feed_data_source.py b/wzdx/models/feed_info/feed_data_source.py index 0f1314a..d8b7a93 100644 --- a/wzdx/models/feed_info/feed_data_source.py +++ b/wzdx/models/feed_info/feed_data_source.py @@ -2,11 +2,39 @@ from datetime import datetime from pydantic import BaseModel, Field + class FeedDataSource(BaseModel): - """WZDx feed data source""" - data_source_id: str = Field(alias="data_source_id") - organization_name: str = Field(None, alias="organization_name") - update_date: Optional[datetime] = Field(None, alias="update_date") - update_frequency: Optional[int] = Field(None, alias="update_frequency") - contact_name: Optional[str] = Field(None, alias="contact_name") - contact_email: Optional[str] = Field(None, alias="contact_email") + """ + 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[str] = Field( + default=None, + alias="contact_email", + description="The email address of the individual or group responsible for the data source.", + ) diff --git a/wzdx/models/feed_info/feed_info.py b/wzdx/models/feed_info/feed_info.py index 47a4dbb..9d77ad7 100644 --- a/wzdx/models/feed_info/feed_info.py +++ b/wzdx/models/feed_info/feed_info.py @@ -6,15 +6,44 @@ class FeedInfo(BaseModel): """ - WZDx feed info metadata. - - See: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FeedInfo.md + 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 = None - version: str = None - license: Optional[str] = None - data_sources: list[FeedDataSource] = Field(None, alias="data_sources") - update_date: datetime = Field(None, alias="update_date") - update_frequency: Optional[int] = Field(None, alias="update_frequency") - contact_name: Optional[str] = Field(None, alias="contact_name") - contact_email: Optional[EmailStr] = Field(None, alias="contact_email") + + 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.", + ) diff --git a/wzdx/models/field_device_feed/device_feed.py b/wzdx/models/field_device_feed/device_feed.py index aea3fe3..5549704 100644 --- a/wzdx/models/field_device_feed/device_feed.py +++ b/wzdx/models/field_device_feed/device_feed.py @@ -7,7 +7,26 @@ class DeviceFeed(BaseModel): - feed_info: FeedInfo = Field(alias="feed_info") - type: str - features: list[FieldDeviceFeature] - bbox: Optional[list[float]] = None + """ + The DeviceFeed object is the root (highest level) object of a WZDx Device Feed. There is one DeviceFeed object per feed GeoJSON document. The DeviceFeed is a [GeoJSON FeatureCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.3) object. + + The DeviceFeed contains information (location, status, live data) about field devices deployed on the roadway in work zones. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/DeviceFeed.md + """ + + feed_info: FeedInfo = Field( + alias="feed_info", description="Information about the data feed." + ) + type: str = Field( + alias="type", + description="The GeoJSON object type. For WZDx, this must be the string FeatureCollection.", + ) + features: list[FieldDeviceFeature] = Field( + alias="features", + description="An array of GeoJSON [Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2) objects which each represent a field device deployed in a work zone.", + ) + bbox: Optional[list[float]] = Field( + default=None, + description="Information on the coordinate range for all FieldDeviceFeatures in the feed. The value must be an array of length 2n where n is the number of dimensions represented in the contained geometries, with all axes of the most southwesterly point followed by all axes of the more northeasterly point. The axes order of a bbox follows the axes order of geometries.", + ) diff --git a/wzdx/models/field_device_feed/field_device_core_details.py b/wzdx/models/field_device_feed/field_device_core_details.py index 8dc878f..0044ab0 100644 --- a/wzdx/models/field_device_feed/field_device_core_details.py +++ b/wzdx/models/field_device_feed/field_device_core_details.py @@ -8,21 +8,85 @@ class FieldDeviceCoreDetails(BaseModel): - device_type: FieldDeviceType = Field(alias="device_type") - data_source_id: str = Field(alias="data_source_id") - device_status: FieldDeviceStatus = Field(alias="device_status") - update_date: datetime = Field(alias="update_date") - has_automatic_location: bool = Field(alias="has_automatic_location") - road_direction: Optional[Direction] = Field(None, alias="road_direction") - road_names: Optional[list[str]] = Field(None, alias="road_names") - name: Optional[str] = Field(None, alias="name") - description: Optional[str] = Field(None, alias="description") - status_messages: Optional[list[str]] = Field(None, alias="status_messages") - is_moving: Optional[bool] = Field(None, alias="is_moving") - road_event_ids: Optional[list[str]] = Field(None, alias="road_event_ids") - milepost: Optional[float] = Field(None, alias="milepost") - make: Optional[str] = Field(None, alias="make") - model: Optional[str] = Field(None, alias="model") - serial_number: Optional[str] = Field(None, alias="serial_number") - firmware_version: Optional[str] = Field(None, alias="firmware_version") - velocity_kph: Optional[float] = Field(None, alias="velocity_kph") + """ + The FieldDeviceCoreDetails object represents the core details—both configuration and current state—of a field device that are shared by all types of field devices. + The FieldDeviceCoreDetails object cannot occur directly in a data feed and does not represent a field device on its own. It is used as the value of the core_details + property on every specific type of field device, each represented by its own object. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FieldDeviceCoreDetails.md + """ + + device_type: FieldDeviceType = Field( + alias="device_type", description="The type of field device." + ) + data_source_id: str = Field( + alias="data_source_id", + description="Identifies the data source from which the field device data originates.", + ) + device_status: FieldDeviceStatus = Field( + alias="device_status", + description="The operational status of the field device. The value of this property indicates if the device is ok or in an error or warning state.", + ) + update_date: datetime = Field( + alias="update_date", + description="The UTC time and date when the field device information was updated.", + ) + has_automatic_location: bool = Field( + alias="has_automatic_location", + description="A yes/no value indicating if the field device location (parent FieldDeviceFeature's geometry) is determined automatically from an onboard GPS (true) or manually set/overridden (false).", + ) + road_direction: Optional[Direction] = Field( + None, + alias="road_direction", + description="The direction of the road that the field device is on. This value indicates the direction of the traffic flow of the road, not a real heading angle.", + ) + road_names: Optional[list[str]] = Field( + None, + alias="road_names", + description="A list of publicly known names of the road on which the device is located. This may include the road number designated by a jurisdiction such as a county, state or interstate (e.g. I-5, VT 133).", + ) + name: Optional[str] = Field( + None, alias="name", description="A human-readable name for the field device." + ) + description: Optional[str] = Field( + None, alias="description", description="A description of the field device." + ) + status_messages: Optional[list[str]] = Field( + None, + alias="status_messages", + description="A list of messages associated with the device's status, if applicable. Used to provide additional information about the status such as specific warning or error messages.", + ) + is_moving: Optional[bool] = Field( + None, + alias="is_moving", + description="A yes/no value indicating if the device is actively moving (not statically placed) as part of a mobile work zone operation.", + ) + road_event_ids: Optional[list[str]] = Field( + None, + alias="road_event_ids", + description="A list of one or more IDs of a RoadEventFeature that the device is associated with.", + ) + milepost: Optional[float] = Field( + None, + alias="milepost", + description="The linear distance measured against a milepost marker along a roadway where the device is located.", + ) + make: Optional[str] = Field( + None, alias="make", description="The make or manufacturer of the device." + ) + model: Optional[str] = Field( + None, alias="model", description="The model of the device." + ) + serial_number: Optional[str] = Field( + None, alias="serial_number", description="The serial number of the device." + ) + firmware_version: Optional[str] = Field( + None, + alias="firmware_version", + description="The version of firmware the device is using to operate.", + ) + velocity_kph: Optional[float] = Field( + None, + alias="velocity_kph", + description="The velocity of the device in kilometers per hour.", + ) diff --git a/wzdx/models/field_device_feed/field_device_feature.py b/wzdx/models/field_device_feed/field_device_feature.py index 89eca1a..2fbfe73 100644 --- a/wzdx/models/field_device_feed/field_device_feature.py +++ b/wzdx/models/field_device_feed/field_device_feature.py @@ -1,13 +1,37 @@ from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field from ..geometry.geojson_geometry import GeoJsonGeometry from .properties.field_device_properties import FieldDeviceProperties class FieldDeviceFeature(BaseModel): - id: str - type: str - properties: FieldDeviceProperties - geometry: GeoJsonGeometry - bbox: Optional[list[float]] = None + """ + The FieldDeviceFeature object is a GeoJSON Feature representing a deployed field device. This object contains the specific details of the field device, similar to how the RoadEventFeature object in a WZDx Feed contains the road event object (WorkZoneRoadEvent or DetourRoadEvent. + + Currently, only point devices are supported. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FieldDeviceFeature.md + """ + + id: str = Field( + alias="id", + description="A unique identifier issued by the data feed provider to identify the field device. 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.", + ) + type: str = Field( + alias="type", + description="The GeoJSON object type. This MUST be the string Feature.", + ) + properties: FieldDeviceProperties = Field( + alias="properties", + description="The specific details of the field device.", + ) + geometry: GeoJsonGeometry = Field( + alias="geometry", + description="The geometry of the field device, indicating its location. The Geometry object's type property MUST be Point.", + ) + bbox: Optional[list[float]] = Field( + default=None, + alias="bbox", + description="Information on the coordinate range for this field device. Must be an array of length 2n where n is the number of dimensions represented in the geometry property, with all axes of the most southwesterly point followed by all axes of the more northeasterly point. The axes order of a bbox follows the axes order of the geometry.", + ) diff --git a/wzdx/models/field_device_feed/field_device_status.py b/wzdx/models/field_device_feed/field_device_status.py index 67a67d2..55c5733 100644 --- a/wzdx/models/field_device_feed/field_device_status.py +++ b/wzdx/models/field_device_feed/field_device_status.py @@ -1,7 +1,14 @@ from enum import Enum + class FieldDeviceStatus(str, Enum): - ERROR = "error" - OK = "ok" - UNKNOWN = "unknown" - WARNING = "warning" + """ + The FieldDeviceStatus enumerated type describes the operational status of a field device. The status indicates the health of the device. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/enumerated-types/FieldDeviceStatus.md + """ + + OK = "ok" # The device is turned on and working without issue. + WARNING = "warning" # The device is functional but is impaired or impacted in a way that is not critical to operation. + ERROR = "error" # The device is impaired such that it is not able to perform one or more necessary functions. + UNKNOWN = "unknown" # The device's operational status is not known. diff --git a/wzdx/models/field_device_feed/field_device_type.py b/wzdx/models/field_device_feed/field_device_type.py index 53b5aca..c3ceada 100644 --- a/wzdx/models/field_device_feed/field_device_type.py +++ b/wzdx/models/field_device_feed/field_device_type.py @@ -1,5 +1,10 @@ from typing_extensions import Literal +""" +The FieldDeviceType enumerated type enumerates all types of field devices described by the specification. + +Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/enumerated-types/FieldDeviceType.md +""" FieldDeviceType = Literal[ "arrow-board", "camera", diff --git a/wzdx/models/field_device_feed/properties/arrow_board.py b/wzdx/models/field_device_feed/properties/arrow_board.py index e3021ff..ed5d000 100644 --- a/wzdx/models/field_device_feed/properties/arrow_board.py +++ b/wzdx/models/field_device_feed/properties/arrow_board.py @@ -3,30 +3,64 @@ from enum import Enum from ..field_device_core_details import FieldDeviceCoreDetails + class ArrowBoardPattern(str, Enum): - BLANK = "blank" - RIGHT_ARROW_STATIC = "right-arrow-static" - RIGHT_ARROW_FLASHING = "right-arrow-flashing" - RIGHT_ARROW_SEQUENTIAL = "right-arrow-sequential" - RIGHT_CHEVRON_STATIC = "right-chevron-static" - RIGHT_CHEVRON_FLASHING = "right-chevron-flashing" - RIGHT_CHEVRON_SEQUENTIAL = "right-chevron-sequential" - LEFT_ARROW_STATIC = "left-arrow-static" - LEFT_ARROW_FLASHING = "left-arrow-flashing" - LEFT_ARROW_SEQUENTIAL = "left-arrow-sequential" - LEFT_CHEVRON_STATIC = "left-chevron-static" - LEFT_CHEVRON_FLASHING = "left-chevron-flashing" - LEFT_CHEVRON_SEQUENTIAL = "left-chevron-sequential" - BIDIRECTIONAL_ARROW_STATIC = "bidirectional-arrow-static" - BIDIRECTIONAL_ARROW_FLASHING = "bidirectional-arrow-flashing" - LINE_FLASHING = "line-flashing" - DIAMONDS_ALTERNATING = "diamonds-alternating" - FOUR_CORNERS_FLASHING = "four-corners-flashing" - UNKNOWN = "unknown" + """ + The ArrowBoardPattern enumerated type defines a list of options for the posted pattern on an ArrowBoard. + + If the arrow board pattern does not exactly match one of the values described, the closest pattern should be used. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/enumerated-types/ArrowBoardPattern.md + """ + + BLANK = "blank" # No pattern; the board is not displaying anything. + RIGHT_ARROW_STATIC = "right-arrow-static" # Merge right represented by an arrow pattern (e.g. -->) that does not flash or move. + RIGHT_ARROW_FLASHING = "right-arrow-flashing" # Merge right represented by an arrow pattern (e.g. -->) that flashes on/off. + RIGHT_ARROW_SEQUENTIAL = "right-arrow-sequential" # Merge right represented by an arrow pattern (e.g. -->) that is displayed in a progressing sequence (e.g. > -> --> or - -- -->). + RIGHT_CHEVRON_STATIC = "right-chevron-static" # Merge right represented by a pattern of chevrons (e.g. >>>) that does not flash or move. + RIGHT_CHEVRON_FLASHING = "right-chevron-flashing" # Merge right represented by a pattern of chevrons (e.g. >>>) that flashes on/off. + RIGHT_CHEVRON_SEQUENTIAL = "right-chevron-sequential" # Merge right represented by a pattern of chevrons that is displayed in a progressing sequence. + LEFT_ARROW_STATIC = "left-arrow-static" # Merge left represented by an arrow pattern (e.g. <--) that does not flash or move. + LEFT_ARROW_FLASHING = "left-arrow-flashing" # Merge left represented by an arrow pattern (e.g. <--) that flashes on/off. + LEFT_ARROW_SEQUENTIAL = "left-arrow-sequential" # Merge left represented by an arrow pattern (e.g. <--) that is displayed in a progressing sequence (e.g. < <- <-- or - -- <--). + LEFT_CHEVRON_STATIC = "left-chevron-static" # Merge left represented by a pattern of chevrons (e.g. <<<) that does not flash or move. + LEFT_CHEVRON_FLASHING = "left-chevron-flashing" # Merge left represented by a pattern of chevrons (e.g. <<<) that flashes on/off. + LEFT_CHEVRON_SEQUENTIAL = "left-chevron-sequential" # Merge left represented by a pattern of chevrons that is displayed in a progressing sequence. + BIDIRECTIONAL_ARROW_STATIC = "bidirectional-arrow-static" # Split (merge left or right) represented by arrows pointing both left and right (e.g. <-->) that does not flash or move. + BIDIRECTIONAL_ARROW_FLASHING = "bidirectional-arrow-flashing" # Split (merge left or right) represented by arrows pointing both left and right (e.g. <-->) that flashes on/off. + LINE_FLASHING = "line-flashing" # A flashing line or bar (e.g. ---), indicating warning/caution, not a merge. + DIAMONDS_ALTERNATING = "diamonds-alternating" # An alternating display of two diamond shapes (e.g. ◇ ◇), indicating warning/caution, not a merge. + FOUR_CORNERS_FLASHING = "four-corners-flashing" # Four dots on the corners of the board which flash, indicating warning/caution, not a merge. + UNKNOWN = "unknown" # The arrow board pattern is not known. + class ArrowBoardCoreDetails(FieldDeviceCoreDetails): device_type: Literal["arrow-board"] = "arrow-board" + class ArrowBoard(BaseModel): - core_details: ArrowBoardCoreDetails = Field(alias="core_details") - pattern: Optional[ArrowBoardPattern] = None + """ + The ArrowBoard object describes an electronic, connected arrow board which can display + an arrow pattern to direct traffic. Arrow boards are often placed at the beginning of a + lane closure—thus knowing the location of an arrow board can assist in programmatically + generating a WZDx road event with verified spatial information. + + The ArrowBoard is a type of field device; it has a :class:`core_details` property which + contains the :class:`FieldDeviceCoreDetails` and exists within a :class:`FieldDeviceFeature`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/ArrowBoard.md + """ + + core_details: ArrowBoardCoreDetails = Field( + alias="core_details", + description="The core details of the field device that are shared by all types of field devices, not specific to arrow boards.", + ) + pattern: ArrowBoardPattern = Field( + alias="pattern", + description="The current pattern displayed on the arrow board. Note this includes blank, which indicates that nothing is shown on the arrow board.", + ) + is_in_transport_position: Optional[bool] = Field( + default=None, + alias="is_in_transport_position", + description="A yes/no value indicating if the arrow board is in the stowed/transport position (true) or deployed/upright position (false).", + ) diff --git a/wzdx/models/field_device_feed/properties/camera.py b/wzdx/models/field_device_feed/properties/camera.py index 547b0b5..2d469ca 100644 --- a/wzdx/models/field_device_feed/properties/camera.py +++ b/wzdx/models/field_device_feed/properties/camera.py @@ -1,12 +1,37 @@ from typing import Literal, Optional from datetime import datetime -from pydantic import BaseModel, Field, HttpUrl +from pydantic import BaseModel, Field, HttpUrl, model_validator from ..field_device_core_details import FieldDeviceCoreDetails + class CameraCoreDetails(FieldDeviceCoreDetails): device_type: Literal["camera"] = "camera" + class Camera(BaseModel): - core_details: CameraCoreDetails = Field(alias="core_details") - image_url: Optional[HttpUrl] = Field(None, alias="image_url") - image_timestamp: Optional[datetime] = Field(None, alias="image_timestamp") + """ + The Camera object describes a camera device deployed in the field, capable of capturing still images. + + The Camera is a type of field device; it has a core_details property which contains the :class:`FieldDeviceCoreDetails` and exists within a :class:`FieldDeviceFeature`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/Camera.md + """ + + core_details: CameraCoreDetails = Field(alias="core_details", description="") + image_url: Optional[HttpUrl] = Field( + None, + alias="image_url", + description="A URL pointing to an image file for the camera image still.", + ) + image_timestamp: Optional[datetime] = Field( + None, + alias="image_timestamp", + description="The UTC date and time when the image was captured.", + ) + + @model_validator(mode="after") + def validate_image_timestamp(self) -> "Camera": + """Validate that image_timestamp is provided when image_url is set.""" + if self.image_url is not None and self.image_timestamp is None: + raise ValueError("image_timestamp is required when image_url is provided") + return self diff --git a/wzdx/models/field_device_feed/properties/dynamic_message_sign.py b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py index f5bbe68..bbfc4b2 100644 --- a/wzdx/models/field_device_feed/properties/dynamic_message_sign.py +++ b/wzdx/models/field_device_feed/properties/dynamic_message_sign.py @@ -2,9 +2,25 @@ from pydantic import BaseModel, Field from ..field_device_core_details import FieldDeviceCoreDetails + class DynamicMessageSignCoreDetails(FieldDeviceCoreDetails): device_type: Literal["dynamic-message-sign"] = "dynamic-message-sign" + class DynamicMessageSign(BaseModel): - core_details: DynamicMessageSignCoreDetails = Field(alias="core_details") - message_multi_string: str = Field(None, alias="message_multi_string") + """ + The DynamicMessageSign object describes a dynamic message sign (DMS)—also known as changeable message sign (CMS) or variable message sign (VMS)—which is an electronic traffic sign deployed on the roadway used to provide information to travelers. + + The DynamicMessageSign is a type of field device; it has a core_details property which contains the :class:`FieldDeviceCoreDetails` and exists within a :class:`FieldDeviceFeature`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/DynamicMessageSign.md + """ + + core_details: DynamicMessageSignCoreDetails = Field( + alias="core_details", + description="The core details of the field device that are shared by all types of field devices, not specific to dynamic message signs.", + ) + message_multi_string: str = Field( + alias="message_multi_string", + description="The MULTI (Mark-Up Language for Transportation Information, see [NTCIP 1203 v03](https://www.ntcip.org/file/2018/11/NTCIP1203v03f.pdf)) formatted string describing the message currently posted to the sign.", + ) diff --git a/wzdx/models/field_device_feed/properties/field_device_properties.py b/wzdx/models/field_device_feed/properties/field_device_properties.py index 8b20079..8e77a48 100644 --- a/wzdx/models/field_device_feed/properties/field_device_properties.py +++ b/wzdx/models/field_device_feed/properties/field_device_properties.py @@ -10,12 +10,31 @@ from .traffic_sensor import TrafficSensor from .traffic_signal import TrafficSignal + def get_device_type(v): - """Discriminator function to get device_type from core_details""" + """ + Extract the ``device_type`` discriminator value from a field device object. + + This helper is used by the Pydantic ``Discriminator`` on ``FieldDeviceProperties`` + to implement a discriminated union based on ``core_details.device_type``. + It supports both raw dictionaries and model instances that expose a + ``core_details`` attribute. + + Args: + v: Either a mapping-like object (typically a ``dict``) containing a + ``"core_details"`` key, or a model instance with a ``core_details`` + attribute that in turn has a ``device_type`` attribute. + + Returns: + The value of ``device_type`` used to select the appropriate concrete + field device model in the ``FieldDeviceProperties`` union, or ``None`` + if it cannot be determined. + """ if isinstance(v, dict): return v.get("core_details", {}).get("device_type") return getattr(v.core_details, "device_type", None) + # Discriminated union based on core_details.device_type FieldDeviceProperties = Annotated[ Annotated[ArrowBoard, Tag("arrow-board")] diff --git a/wzdx/models/field_device_feed/properties/flashing_beacon.py b/wzdx/models/field_device_feed/properties/flashing_beacon.py index 96cbea9..694f281 100644 --- a/wzdx/models/field_device_feed/properties/flashing_beacon.py +++ b/wzdx/models/field_device_feed/properties/flashing_beacon.py @@ -3,17 +3,50 @@ from enum import Enum from ..field_device_core_details import FieldDeviceCoreDetails + class FlashingBeaconFunction(str, Enum): - VEHICLE_ENTERING = "vehicle-entering" - QUEUE_WARNING = "queue-warning" - REDUCED_SPEED = "reduced-speed" - WORKERS_PRESENT = "workers-present" + """ + The FlashingBeaconFunction enumerated type describes a list of options for what a :class:`FlashingBeacon` is being used to indicate. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/enumerated-types/FlashingBeaconFunction.md + """ + + VEHICLE_ENTERING = "vehicle-entering" # Vehicles are entering the roadway. + QUEUE_WARNING = "queue-warning" # There is a queue of vehicles. + REDUCED_SPEED = "reduced-speed" # There is a reduced speed limit. + WORKERS_PRESENT = ( + "workers-present" # There are workers present on or near the roadway. + ) + class FlashingBeaconCoreDetails(FieldDeviceCoreDetails): device_type: Literal["flashing-beacon"] = "flashing-beacon" + class FlashingBeacon(BaseModel): - core_details: FlashingBeaconCoreDetails = Field(alias="core_details") - function: FlashingBeaconFunction = None - is_flashing: Optional[bool] = Field(None, alias="is_flashing") - sign_text: Optional[str] = Field(None, alias="sign_text") + """ + The FlashingBeacon object describes a flashing warning beacon used to supplement a temporary traffic control device. A flashing warning beacon is mounted on a sign or channelizing device and used to indicate a warning condition and capture driver attention. + + The FlashingBeacon is a type of field device; it has a core_details property which contains the :class:`FieldDeviceCoreDetails` and exists within a :class:`FieldDeviceFeature`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/FlashingBeacon.md + """ + + core_details: FlashingBeaconCoreDetails = Field( + alias="core_details", + description="The core details of the field device that are shared by all types of field devices, not specific to flashing beacons.", + ) + function: FlashingBeaconFunction = Field( + alias="function", + description="Describes the function or purpose of the flashing beacon, i.e. what it is being used to indicate.", + ) + is_flashing: Optional[bool] = Field( + None, + alias="is_flashing", + description="A yes/no value indicating if the flashing beacon is currently in use and flashing.", + ) + sign_text: Optional[str] = Field( + None, + alias="sign_text", + description="The text on the sign the beacon is mounted on, if applicable.", + ) diff --git a/wzdx/models/field_device_feed/properties/hybrid_sign.py b/wzdx/models/field_device_feed/properties/hybrid_sign.py index eeedb5d..85cbbc6 100644 --- a/wzdx/models/field_device_feed/properties/hybrid_sign.py +++ b/wzdx/models/field_device_feed/properties/hybrid_sign.py @@ -3,18 +3,47 @@ from enum import Enum from ..field_device_core_details import FieldDeviceCoreDetails + class HybridSignDynamicMessageFunction(str, Enum): - SPEED_LIMIT = "speed-limit" - TRAVEL_TIME = "travel-time" - OTHER = "other" + """ + The HybridSignDynamicMessageFunction enumerated type describes options for the function of the dynamic message displayed by the electronic display on a :class:`HybridSign`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/enumerated-types/HybridSignDynamicMessageFunction.md + """ + + SPEED_LIMIT = "speed-limit" # The message is a speed limit. + TRAVEL_TIME = "travel-time" # The message is a travel time. + OTHER = "other" # The hybrid sign message function is not one of the other options described by this enumerated type. + class HybridSignCoreDetails(FieldDeviceCoreDetails): device_type: Literal["hybrid-sign"] = "hybrid-sign" + class HybridSign(BaseModel): - core_details: HybridSignCoreDetails = Field(alias="core_details") - dynamic_message_function: Optional[HybridSignDynamicMessageFunction] = Field( - None, alias="dynamic_message_function" + """ + The HybridSign object describes a hybrid sign that contains static text (e.g. on an aluminum sign) along with a single electronic message display, used to provide information to travelers. This object is intended to be general to represent hybrid signs with multiple functions, such as variable speed limit signs (VSLS), hybrid travel time signs, and other similar systems. + + The HybridSign is a type of field device; it has a core_details property which contains the :class:`FieldDeviceCoreDetails` and exists within a :class:`FieldDeviceFeature`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/HybridSign.md + """ + + core_details: HybridSignCoreDetails = Field( + alias="core_details", + description="The core details of the field device shared by all field devices types, not specific to hybrid signs.", + ) + dynamic_message_function: HybridSignDynamicMessageFunction = Field( + alias="dynamic_message_function", + description="The function the dynamic message displayed (e.g. a speed limit).", + ) + dynamic_message_text: Optional[str] = Field( + None, + alias="dynamic_message_text", + description="A text representation of the message currently posted to the electronic component of the hybrid sign.", + ) + static_sign_text: Optional[str] = Field( + None, + alias="static_sign_text", + description="The static text on the non-electronic component of the hybrid sign.", ) - dynamic_message_text: Optional[str] = Field(None, alias="dynamic_message_text") - static_sign_text: Optional[str] = Field(None, alias="static_sign_text") diff --git a/wzdx/models/field_device_feed/properties/location_marker.py b/wzdx/models/field_device_feed/properties/location_marker.py index 6cc80cb..cdd5006 100644 --- a/wzdx/models/field_device_feed/properties/location_marker.py +++ b/wzdx/models/field_device_feed/properties/location_marker.py @@ -3,28 +3,67 @@ from enum import Enum from ..field_device_core_details import FieldDeviceCoreDetails + class MarkedLocationType(str, Enum): - AFAD = "afad" - DELINEATOR = "delineator" - FLAGGER = "flagger" - LANE_SHIFT = "lane-shift" - LANE_CLOSURE = "lane-closure" - PERSONAL_DEVICE = "personal-device" - RAMP_CLOSURE = "ramp-closure" - ROAD_CLOSURE = "road-closure" - ROAD_EVENT_START = "road-event-start" - ROAD_EVENT_END = "road-event-end" - WORK_TRUCK_WITH_LIGHTS_FLASHING = "work-truck-with-lights-flashing" - WORK_ZONE_START = "work-zone-start" - WORK_ZONE_END = "work-zone-end" + """ + The MarkedLocationType enumerated type describes options for what a :class:`MarkedLocation` can mark, such as the start or end of a road event. + + https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/enumerated-types/MarkedLocationType.md + """ + + AFAD = "afad" # An automatic flagger assistance device. + DELINEATOR = "delineator" # A generic delineation point in a work zone. This value can be used for most types of marked locations that don't match any of the other values. + FLAGGER = "flagger" # A human who is directing traffic. + LANE_SHIFT = "lane-shift" # A lane shift. + LANE_CLOSURE = "lane-closure" # One or more lanes are closed. + PERSONAL_DEVICE = "personal-device" # A connected device that is worn or carried by an individual worker in a work zone. + RAMP_CLOSURE = ( + "ramp-closure" # The start of a closed ramp onto or off a main road or highway. + ) + ROAD_CLOSURE = "road-closure" # The start of a closed road. + ROAD_EVENT_START = "road-event-start" # The start point of a road event. + ROAD_EVENT_END = "road-event-end" # The end point of a road event. + WORK_TRUCK_WITH_LIGHTS_FLASHING = "work-truck-with-lights-flashing" # A work truck with lights flashing, actively engaged in construction or maintenance activity on the roadway. + WORK_ZONE_START = "work-zone-start" # The start point of a work zone. + WORK_ZONE_END = "work-zone-end" # The end point of a work zone. + class MarkedLocation(BaseModel): - type: MarkedLocationType - road_event_id: Optional[str] = None + """ + The MarkedLocation object describes a specific location where a LocationMarker is placed, such as the start or end of a work zone road event. The marked location is typically within a road event, but is not required to be. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/MarkedLocation.md + """ + + type: MarkedLocationType = Field( + alias="type", + description="The type of location (e.g. start or end) that is marked.", + ) + road_event_id: Optional[str] = Field( + None, + alias="road_event_id", + description="The ID of a RoadEventFeature that the MarkedLocation applies to.", + ) + class MarkedLocationCoreDetails(FieldDeviceCoreDetails): device_type: Literal["location-marker"] = "location-marker" + class LocationMarker(BaseModel): - core_details: MarkedLocationCoreDetails = Field(alias="core_details") - marked_locations: Optional[list[MarkedLocation]] = Field(None, alias="marked_locations") + """ + The LocationMarker object describes any GPS-enabled ITS device that is placed at a point on a roadway to dynamically know the location of something (often the beginning or end of a work zone). The LocationMarker contains a list of one or more MarkedLocation objects which indicate the type of location (such as the start or end) and optionally the ID of a RoadEventFeature that the location is associated with. + + The LocationMarker is a type of field device; it has a core_details property which contains the FieldDeviceCoreDetails and exists within a FieldDeviceFeature. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/LocationMarker.md + """ + + core_details: MarkedLocationCoreDetails = Field( + alias="core_details", + description="The core details of the field device shared by all field devices types, not specific to the location marker.", + ) + marked_locations: list[MarkedLocation] = Field( + alias="marked_locations", + description="A list of locations that the LocationMarker is marking.", + ) diff --git a/wzdx/models/field_device_feed/properties/traffic_sensor.py b/wzdx/models/field_device_feed/properties/traffic_sensor.py index dfe3cc4..34a3143 100644 --- a/wzdx/models/field_device_feed/properties/traffic_sensor.py +++ b/wzdx/models/field_device_feed/properties/traffic_sensor.py @@ -2,25 +2,84 @@ from pydantic import BaseModel, Field from ..field_device_core_details import FieldDeviceCoreDetails + class TrafficSensorLaneData(BaseModel): - lane_order: int = Field(alias="lane_order") - road_event_id: Optional[str] = Field(None, alias="road_event_id") - average_speed_kph: Optional[float] = Field(None, alias="average_speed_kph") - volume_vph: Optional[float] = Field(None, alias="volume_vph") - occupancy_percent: Optional[float] = Field(None, alias="occupancy_percent") + """ + The TrafficSensorLaneData object describes data for a single lane measured by a :class:`TrafficSensor` deployed on the roadway. + + Note this structure allows a single :class:`TrafficSensor` to provide data across lanes on multiple road events. + + Description: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/TrafficSensorLaneData.md + """ + + lane_order: int = Field( + alias="lane_order", + description="The lane's position in sequence on the roadway. If road_event_id is provided, the value of this property corresponds to the associated road event's Lane's order property.", + ) + road_event_id: Optional[str] = Field( + None, + alias="road_event_id", + description="The ID of a RoadEventFeature which the measured lane occurs in, if applicable.", + ) + average_speed_kph: Optional[float] = Field( + None, + alias="average_speed_kph", + description="The average speed of traffic in the lane over the collection interval (in kilometers per hour).", + ) + volume_vph: Optional[float] = Field( + None, + alias="volume_vph", + description="The rate of vehicles passing by the sensor in the lane during the collection interval (in vehicles per hour).", + ) + occupancy_percent: Optional[float] = Field( + None, + alias="occupancy_percent", + description="The percent of time the lane monitored by the sensor was occupied by a vehicle over the collection interval.", + ) + class TrafficSensorCoreDetails(FieldDeviceCoreDetails): device_type: Literal["traffic-sensor"] = "traffic-sensor" + class TrafficSensor(BaseModel): - core_details: TrafficSensorCoreDetails = Field(alias="core_details") + """ + The TrafficSensor object describes a traffic sensor deployed on a roadway which captures traffic metrics (e.g. speed, volume, occupancy) over a collection interval. The TrafficSensor can describe lane-level traffic data if available and if associated with a road event (e.g. :class:`WorkZoneRoadEvent`). + + The TrafficSensor is a type of field device; it has a core_details property which contains the :class:`FieldDeviceCoreDetails` and exists within a :class:`FieldDeviceFeature`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/TrafficSensor.md + """ + + core_details: TrafficSensorCoreDetails = Field( + alias="core_details", + description="The core details of the field device shared by all field devices types, not specific to traffic sensors.", + ) collection_interval_start_date: str = Field( - None, alias="collection_interval_start_date" + alias="collection_interval_start_date", + description="The UTC date and time where the TrafficSensor data began being collected at. The averages and totals contained in the TrafficSensor data apply to the inclusive interval of collection_interval_start_date to collection_interval_end_date.", ) collection_interval_end_date: str = Field( - None, alias="collection_interval_end_date" + alias="collection_interval_end_date", + description="The UTC date and time where the TrafficSensor collection interval ended. The averages and totals contained in the TrafficSensor data apply to the inclusive interval of collection_interval_start_date to collection_interval_end_date.", + ) + average_speed_kph: Optional[float] = Field( + None, + alias="average_speed_kph", + description="The average speed of vehicles across all lanes over the collection interval in kilometers per hour.", + ) + volume_vph: Optional[float] = Field( + None, + alias="volume_vph", + description="The rate of vehicles passing by the sensor during the collection interval in vehicles per hour.", + ) + occupancy_percent: Optional[float] = Field( + None, + alias="occupancy_percent", + description="The percent of time the roadway section monitored by the sensor was occupied by a vehicle over the collection interval.", + ) + lane_data: Optional[list[TrafficSensorLaneData]] = Field( + None, + alias="lane_data", + description="A list of objects each describing traffic data for a specific lane.", ) - average_speed_kph: Optional[float] = Field(None, alias="average_speed_kph") - volume_vph: Optional[float] = Field(None, alias="volume_vph") - occupancy_percent: Optional[float] = Field(None, alias="occupancy_percent") - lane_data: Optional[list[TrafficSensorLaneData]] = Field(None, alias="lane_data") diff --git a/wzdx/models/field_device_feed/properties/traffic_signal.py b/wzdx/models/field_device_feed/properties/traffic_signal.py index d732a8c..e73eaf2 100644 --- a/wzdx/models/field_device_feed/properties/traffic_signal.py +++ b/wzdx/models/field_device_feed/properties/traffic_signal.py @@ -3,19 +3,43 @@ from enum import Enum from ..field_device_core_details import FieldDeviceCoreDetails + class TrafficSignalMode(str, Enum): - BLANK = "blank" - FLASHING_RED = "flashing-red" - FLASHING_YELLOW = "flashing-yellow" - FULLY_ACTUATED = "fully-actuated" - MANUAL = "manual" - PRE_TIMED = "pre-timed" - SEMI_ACTUATED = "semi-actuated" - UNKNOWN = "unknown" + """ + The TrafficSignalMode enumerated type describes the current operating mode of a :class:`TrafficSignal`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/enumerated-types/TrafficSignalMode.md + """ + + BLANK = "blank" # The signal is not displaying anything. + FLASHING_RED = "flashing-red" # The signal is in a flashing red state that could be part of startup or fault. + FLASHING_YELLOW = "flashing-yellow" # The signal is in a flashing yellow state that could be part of startup or fault. + FULLY_ACTUATED = ( + "fully-actuated" # The signal is using an external trigger for all movements. + ) + MANUAL = "manual" # The signal is using a manual trigger. + PRE_TIMED = "pre-timed" # The signal is using a timed cycle. + SEMI_ACTUATED = "semi-actuated" # The signal is using an external trigger only for the minor movements. + UNKNOWN = "unknown" # The current operating mode is not known. + class TrafficSignalCoreDetails(FieldDeviceCoreDetails): device_type: Literal["traffic-signal"] = "traffic-signal" + class TrafficSignal(BaseModel): - core_details: TrafficSignalCoreDetails = Field(alias="core_details") - mode: TrafficSignalMode = None + """ + The TrafficSignal object describes a temporary traffic signal deployed on a roadway. + + The TrafficSignal is a type of field device; it has a core_details property which contains the :class:`FieldDeviceCoreDetails` and exists within a :class:`FieldDeviceFeature`. + + Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/TrafficSignal.md + """ + + core_details: TrafficSignalCoreDetails = Field( + alias="core_details", + description="The core details of the traffic signal device.", + ) + mode: TrafficSignalMode = Field( + alias="mode", description="The current operating mode of the traffic signal." + ) From 9d0502abe1f57f5c3d05925e92ae6028c7166d9e Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 6 Jan 2026 17:34:14 -0700 Subject: [PATCH 10/14] Adding defaults for literals and updating date tools --- pyproject.toml | 1 - wzdx/models/field_device_feed/device_feed.py | 5 +++-- wzdx/models/field_device_feed/field_device_feature.py | 5 +++-- wzdx/models/geometry/geojson_linestring.py | 8 +++++--- wzdx/models/geometry/geojson_multipoint.py | 8 +++++--- wzdx/models/geometry/geojson_point.py | 10 +++++++--- wzdx/models/geometry/geojson_polygon.py | 10 +++++++--- wzdx/tools/date_tools.py | 4 ++-- 8 files changed, 32 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 19c1b64..002292e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ pytest-cov = ">=5.0.0,<8.0.0" time-machine = "^2.10.0" poetry-plugin-export = "^1.6.0" twine = ">=5.0.0,<7.0.0" -pydantic = {extras = ["email"], version = "^2.12.5"} [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] diff --git a/wzdx/models/field_device_feed/device_feed.py b/wzdx/models/field_device_feed/device_feed.py index 5549704..b437eb6 100644 --- a/wzdx/models/field_device_feed/device_feed.py +++ b/wzdx/models/field_device_feed/device_feed.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, Field from ..feed_info.feed_info import FeedInfo @@ -18,7 +18,8 @@ class DeviceFeed(BaseModel): feed_info: FeedInfo = Field( alias="feed_info", description="Information about the data feed." ) - type: str = Field( + type: Literal["FeatureCollection"] = Field( + default="FeatureCollection", alias="type", description="The GeoJSON object type. For WZDx, this must be the string FeatureCollection.", ) diff --git a/wzdx/models/field_device_feed/field_device_feature.py b/wzdx/models/field_device_feed/field_device_feature.py index 2fbfe73..9325e1b 100644 --- a/wzdx/models/field_device_feed/field_device_feature.py +++ b/wzdx/models/field_device_feed/field_device_feature.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, Field from ..geometry.geojson_geometry import GeoJsonGeometry @@ -18,7 +18,8 @@ class FieldDeviceFeature(BaseModel): alias="id", description="A unique identifier issued by the data feed provider to identify the field device. 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.", ) - type: str = Field( + type: Literal["Feature"] = Field( + default="Feature", alias="type", description="The GeoJSON object type. This MUST be the string Feature.", ) diff --git a/wzdx/models/geometry/geojson_linestring.py b/wzdx/models/geometry/geojson_linestring.py index 1bdde39..d9dceb6 100644 --- a/wzdx/models/geometry/geojson_linestring.py +++ b/wzdx/models/geometry/geojson_linestring.py @@ -1,7 +1,9 @@ from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field + class GeoJsonLineString(BaseModel): """GeoJSON LineString geometry""" - type: Literal["LineString"] - coordinates: list[list[float]] # Array of positions + + type: Literal["LineString"] = Field(default="LineString", alias="type") + coordinates: list[list[float]] = Field(alias="coordinates") diff --git a/wzdx/models/geometry/geojson_multipoint.py b/wzdx/models/geometry/geojson_multipoint.py index 1e76f1a..c6f2f53 100644 --- a/wzdx/models/geometry/geojson_multipoint.py +++ b/wzdx/models/geometry/geojson_multipoint.py @@ -1,7 +1,9 @@ from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field + class GeoJsonMultiPoint(BaseModel): """GeoJSON MultiPoint geometry""" - type: Literal["MultiPoint"] - coordinates: list[list[float]] # Array of positions + + type: Literal["MultiPoint"] = Field(default="MultiPoint", alias="type") + coordinates: list[list[float]] = Field(alias="coordinates") diff --git a/wzdx/models/geometry/geojson_point.py b/wzdx/models/geometry/geojson_point.py index 84cd498..517b43f 100644 --- a/wzdx/models/geometry/geojson_point.py +++ b/wzdx/models/geometry/geojson_point.py @@ -1,7 +1,11 @@ from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field + class GeoJsonPoint(BaseModel): """GeoJSON Point geometry""" - type: Literal["Point"] - coordinates: list[float] # [longitude, latitude] or [longitude, latitude, elevation] + + type: Literal["Point"] = Field(default="Point", alias="type") + coordinates: list[float] = Field( + alias="coordinates" + ) # [longitude, latitude] or [longitude, latitude, elevation] diff --git a/wzdx/models/geometry/geojson_polygon.py b/wzdx/models/geometry/geojson_polygon.py index 214c16c..6f77523 100644 --- a/wzdx/models/geometry/geojson_polygon.py +++ b/wzdx/models/geometry/geojson_polygon.py @@ -1,7 +1,11 @@ from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field + class GeoJsonPolygon(BaseModel): """GeoJSON Polygon geometry""" - type: Literal["Polygon"] - coordinates: list[list[list[float]]] # Array of linear rings + + type: Literal["Polygon"] = Field(default="Polygon", alias="type") + coordinates: list[list[list[float]]] = Field( + alias="coordinates" + ) # Array of linear rings diff --git a/wzdx/tools/date_tools.py b/wzdx/tools/date_tools.py index edc747b..33db251 100644 --- a/wzdx/tools/date_tools.py +++ b/wzdx/tools/date_tools.py @@ -48,8 +48,8 @@ def parse_datetime_from_unix(time): def datetime_from_unix(time): - # Maximum unix value of 32536850400 due to windows 32-bit signed integer (max valid date is 3001, 1, 19, 21, 59, 59) - if time >= 32536850400: + # Maximum unix value of 32536850399 due to windows 32-bit signed integer (max valid date is 3001, 1, 19, 21, 59, 59) + if time > 32536850399: return datetime.fromtimestamp(time / 1000, tz=timezone.utc) else: return datetime.fromtimestamp(time, tz=timezone.utc) From 8592f2ec067e2749f80a21a515fc88a5d4d4ae54 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 6 Jan 2026 17:38:58 -0700 Subject: [PATCH 11/14] Improving field device feed test --- tests/models/field_device_feed_test.py | 61 ++++++++++++++++++-------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/tests/models/field_device_feed_test.py b/tests/models/field_device_feed_test.py index ae27b20..bc2c44b 100644 --- a/tests/models/field_device_feed_test.py +++ b/tests/models/field_device_feed_test.py @@ -2,33 +2,56 @@ from wzdx.models.field_device_feed.device_feed import DeviceFeed import json + def test_deserialization(): - # Deserialize from JSON string - json_string = open("./tests/data/models/field_device_feed_icone_raw.json").read() + """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) - # Serialize to JSON - json_output = adapter.dump_json(device_feed_list, by_alias=True, exclude_none=True) + # 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" - # Access properties - if device_feed_list and len(device_feed_list) > 0: - device_feed = device_feed_list[0] - for feature in device_feed.features: - print(f"Device ID: {feature.id}") - print(f"Status: {feature.properties.core_details.device_status}") - print( - f"Update Date: {feature.properties.core_details.update_date}, {type(feature.properties.core_details.update_date)}" - ) + # 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 - print("JSON Output", json_output) + # Load expected output + with open("./tests/data/models/field_device_feed_icone_final.json") as f: + expected_object = json.load(f) - expected_object = json.load( - open("./tests/data/models/field_device_feed_icone_final.json") + # 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" - assert ( - device_feed_list[0].model_dump(by_alias=True, exclude_none=True, mode="json") - == expected_object + +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" From d5863bf5c766d77d65145eaa1f0d4c395c824d07 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 6 Jan 2026 17:40:52 -0700 Subject: [PATCH 12/14] updating poetry lockfile --- poetry.lock | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index c71584e..7197fcb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,7 +6,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -591,7 +591,7 @@ version = "2.8.0" description = "DNS toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, @@ -680,7 +680,7 @@ version = "2.3.0" description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, @@ -1837,7 +1837,7 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -1860,7 +1860,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -2927,6 +2927,7 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -2934,7 +2935,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -3200,4 +3201,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "cfda40bcaaf4fcd5daabf584164d4c5bf7abddbd0f8e5eec8dafa20e090a15d5" +content-hash = "24d86cc6deb1abf05569dd83eb4a9ba23430086cb04d852a8f7d50d9202bb15d" From 20527893c13ef1a23e90772f6527b26f04bd2e41 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 6 Jan 2026 17:51:43 -0700 Subject: [PATCH 13/14] Cleaning up data model descriptions --- wzdx/models/feed_info/feed_data_source.py | 6 +- .../field_device_feed/field_device_feature.py | 2 +- .../field_device_feed/properties/camera.py | 5 +- .../connected_work_zone_feed_v10.py | 4 +- .../validation_schema/work_zone_feed_v42.py | 621 +++++++----------- 5 files changed, 231 insertions(+), 407 deletions(-) diff --git a/wzdx/models/feed_info/feed_data_source.py b/wzdx/models/feed_info/feed_data_source.py index d8b7a93..ed0e1b5 100644 --- a/wzdx/models/feed_info/feed_data_source.py +++ b/wzdx/models/feed_info/feed_data_source.py @@ -1,6 +1,6 @@ from typing import Optional from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, EmailStr, Field class FeedDataSource(BaseModel): @@ -12,7 +12,7 @@ class FeedDataSource(BaseModel): 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.", + 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", @@ -33,7 +33,7 @@ class FeedDataSource(BaseModel): alias="contact_name", description="The name of the individual or group responsible for the data source.", ) - contact_email: Optional[str] = Field( + contact_email: Optional[EmailStr] = Field( default=None, alias="contact_email", description="The email address of the individual or group responsible for the data source.", diff --git a/wzdx/models/field_device_feed/field_device_feature.py b/wzdx/models/field_device_feed/field_device_feature.py index 9325e1b..3186953 100644 --- a/wzdx/models/field_device_feed/field_device_feature.py +++ b/wzdx/models/field_device_feed/field_device_feature.py @@ -16,7 +16,7 @@ class FieldDeviceFeature(BaseModel): id: str = Field( alias="id", - description="A unique identifier issued by the data feed provider to identify the field device. 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.", + description="A unique identifier issued by the data feed provider to identify the field device. 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.", ) type: Literal["Feature"] = Field( default="Feature", diff --git a/wzdx/models/field_device_feed/properties/camera.py b/wzdx/models/field_device_feed/properties/camera.py index 2d469ca..8f78d40 100644 --- a/wzdx/models/field_device_feed/properties/camera.py +++ b/wzdx/models/field_device_feed/properties/camera.py @@ -17,7 +17,10 @@ class Camera(BaseModel): Documentation: https://github.com/usdot-jpo-ode/wzdx/blob/develop/spec-content/objects/Camera.md """ - core_details: CameraCoreDetails = Field(alias="core_details", description="") + core_details: CameraCoreDetails = Field( + alias="core_details", + description="The core details of the field device that are shared by all types of field devices, not specific to cameras.", + ) image_url: Optional[HttpUrl] = Field( None, alias="image_url", diff --git a/wzdx/sample_files/validation_schema/connected_work_zone_feed_v10.py b/wzdx/sample_files/validation_schema/connected_work_zone_feed_v10.py index b3f568a..33e5438 100644 --- a/wzdx/sample_files/validation_schema/connected_work_zone_feed_v10.py +++ b/wzdx/sample_files/validation_schema/connected_work_zone_feed_v10.py @@ -73,7 +73,7 @@ ], "properties": { "data_source_id": { - "description": "Unique identifier for the organization providing work zone data. This identifier is a Universally Unique IDentifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", + "description": "Unique identifier for the organization providing work zone data. This identifier is a Universally Unique Identifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", "type": "string", }, "organization_name": { @@ -136,7 +136,7 @@ "required": ["id", "type", "properties", "geometry"], "properties": { "id": { - "description": "A unique identifier issued by the data feed provider to identify the CWZ road event. This identifier is a Universally Unique IDentifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", + "description": "A unique identifier issued by the data feed provider to identify the CWZ road event. This identifier is a Universally Unique Identifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", "type": "string", }, "type": { diff --git a/wzdx/sample_files/validation_schema/work_zone_feed_v42.py b/wzdx/sample_files/validation_schema/work_zone_feed_v42.py index b48220a..7b13561 100644 --- a/wzdx/sample_files/validation_schema/work_zone_feed_v42.py +++ b/wzdx/sample_files/validation_schema/work_zone_feed_v42.py @@ -14,53 +14,44 @@ "properties": { "publisher": { "description": "The organization responsible for publishing the feed", - "type": "string" + "type": "string", }, "contact_name": { "description": "The name of the individual or group responsible for the data feed", - "type": "string" + "type": "string", }, "contact_email": { "description": "The email address of the individual or group responsible for the data feed", "type": "string", - "format": "email" + "format": "email", }, "update_frequency": { "description": "The frequency in seconds at which the data feed is updated", "type": "integer", - "minimum": 1 + "minimum": 1, }, "update_date": { "description": "The UTC date and time when the GeoJSON file (representing the instance of the feed) was generated", "type": "string", - "format": "date-time" + "format": "date-time", }, "version": { "description": "The WZDx specification version used to create the data feed, in 'major.minor' format", "type": "string", - "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", }, "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/\"", - "enum": [ - "https://creativecommons.org/publicdomain/zero/1.0/" - ] + "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/"', + "enum": ["https://creativecommons.org/publicdomain/zero/1.0/"], }, "data_sources": { "description": "A list of specific data sources for the road event data in the feed", "type": "array", - "items": { - "$ref": "#/definitions/FeedDataSource" - }, - "minItems": 1 - } + "items": {"$ref": "#/definitions/FeedDataSource"}, + "minItems": 1, + }, }, - "required": [ - "update_date", - "version", - "publisher", - "data_sources" - ], + "required": ["update_date", "version", "publisher", "data_sources"], "definitions": { "FeedDataSource": { "title": "WZDx Feed Data Source", @@ -68,59 +59,51 @@ "type": "object", "properties": { "data_source_id": { - "description": "Unique identifier for the organization providing work zone data. It is recommended that this identifier is a Universally Unique IDentifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", - "type": "string" + "description": "Unique identifier for the organization providing work zone data. It is recommended that this identifier is a Universally Unique Identifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", + "type": "string", }, "organization_name": { "description": "The name of the organization for the authoritative source of the work zone data", - "type": "string" + "type": "string", }, "contact_name": { "description": "The name of the individual or group responsible for the data source", - "type": "string" + "type": "string", }, "contact_email": { "description": "The email address of the individual or group responsible for the data source", "type": "string", - "format": "email" + "format": "email", }, "update_frequency": { "description": "The frequency in seconds at which the data source is updated", "type": "integer", - "minimum": 1 + "minimum": 1, }, "update_date": { "description": "The UTC date and time when the data source was last updated", "type": "string", - "format": "date-time" + "format": "date-time", }, "lrs_type": { "description": "**DEPRECATED** Describes the type of linear referencing system used for the milepost measurements", - "type": "string" + "type": "string", }, "lrs_url": { "description": "**DEPRECATED** A URL where additional information on the LRS information and transformation information is stored", "type": "string", - "format": "uri" + "format": "uri", }, "location_verify_method": { "description": "***DEPRECATED***The method used to verify the accuracy of the location information", - "type": "string" - } + "type": "string", + }, }, - "required": [ - "data_source_id", - "organization_name" - ] + "required": ["data_source_id", "organization_name"], } - } - }, - "type": { - "description": "The GeoJSON type", - "enum": [ - "FeatureCollection" - ] + }, }, + "type": {"description": "The GeoJSON type", "enum": ["FeatureCollection"]}, "features": { "description": "An array of GeoJSON Feature objects which represent WZDx road events", "type": "array", @@ -133,25 +116,16 @@ "core_details": { "properties": { "event_type": { - "enum": [ - "work-zone", - "detour" - ] + "enum": ["work-zone", "detour"] } }, - "required": [ - "event_type" - ] + "required": ["event_type"], } }, - "required": [ - "core_details" - ] + "required": ["core_details"], } }, - "required": [ - "properties" - ] + "required": ["properties"], }, { "$id": "https://raw.githubusercontent.com/usdot-jpo-ode/wzdx/main/schemas/4.2/RoadEventFeature.json", @@ -161,14 +135,12 @@ "type": "object", "properties": { "id": { - "description": "A unique identifier issued by the data feed provider to identify the WZDx road event. It is recommended that this identifier is a Universally Unique IDentifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", - "type": "string" + "description": "A unique identifier issued by the data feed provider to identify the WZDx road event. It is recommended that this identifier is a Universally Unique Identifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", + "type": "string", }, "type": { "description": "The GeoJSON object type; must be 'Feature'", - "enum": [ - "Feature" - ] + "enum": ["Feature"], }, "properties": { "type": "object", @@ -177,17 +149,11 @@ "$ref": "#/definitions/RoadEventCoreDetails" } }, - "required": [ - "core_details" - ], + "required": ["core_details"], "oneOf": [ - { - "$ref": "#/definitions/WorkZoneRoadEvent" - }, - { - "$ref": "#/definitions/DetourRoadEvent" - } - ] + {"$ref": "#/definitions/WorkZoneRoadEvent"}, + {"$ref": "#/definitions/DetourRoadEvent"}, + ], }, "geometry": { "oneOf": [ @@ -196,16 +162,11 @@ "$id": "https://geojson.org/schema/LineString.json", "title": "GeoJSON LineString", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "LineString" - ] + "enum": ["LineString"], }, "coordinates": { "type": "array", @@ -213,55 +174,42 @@ "items": { "type": "array", "minItems": 2, - "items": { - "type": "number" - } - } + "items": {"type": "number"}, + }, }, "bbox": { "type": "array", "minItems": 4, - "items": { - "type": "number" - } - } - } + "items": {"type": "number"}, + }, + }, }, { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://geojson.org/schema/MultiPoint.json", "title": "GeoJSON MultiPoint", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "MultiPoint" - ] + "enum": ["MultiPoint"], }, "coordinates": { "type": "array", "items": { "type": "array", "minItems": 2, - "items": { - "type": "number" - } - } + "items": {"type": "number"}, + }, }, "bbox": { "type": "array", "minItems": 4, - "items": { - "type": "number" - } - } - } - } + "items": {"type": "number"}, + }, + }, + }, ] }, "bbox": { @@ -271,17 +219,10 @@ "description": "Information on the coordinate range for a Geometry, Feature, or FeatureCollection", "type": "array", "minItems": 4, - "items": { - "type": "number" - } - } + "items": {"type": "number"}, + }, }, - "required": [ - "id", - "type", - "properties", - "geometry" - ], + "required": ["id", "type", "properties", "geometry"], "definitions": { "WorkZoneRoadEvent": { "title": "Work Zone Road Event", @@ -292,18 +233,12 @@ "properties": { "core_details": { "properties": { - "event_type": { - "const": "work-zone" - } + "event_type": {"const": "work-zone"} }, - "required": [ - "event_type" - ] + "required": ["event_type"], } }, - "required": [ - "core_details" - ] + "required": ["core_details"], }, { "properties": { @@ -312,51 +247,51 @@ }, "beginning_cross_street": { "description": "Name or number of the nearest cross street along the roadway where the event begins", - "type": "string" + "type": "string", }, "ending_cross_street": { "description": "Name or number of the nearest cross street along the roadway where the event ends", - "type": "string" + "type": "string", }, "beginning_milepost": { "description": "The linear distance measured against a milepost marker along a roadway where the event begins", "type": "number", - "minimum": 0 + "minimum": 0, }, "ending_milepost": { "description": "The linear distance measured against a milepost marker along a roadway where the event ends", "type": "number", - "minimum": 0 + "minimum": 0, }, "is_start_position_verified": { "description": "Indicates if the start position (first geometric coordinate pair) is based on actual reported data from a GPS-equipped device that measured the location of the start of the work zone.", - "type": "boolean" + "type": "boolean", }, "is_end_position_verified": { "description": "Indicates if the end position (last geometric coordinate pair) is based on actual reported data from a GPS-equipped device that measured the location of the end of the work zone.", - "type": "boolean" + "type": "boolean", }, "start_date": { "description": "The UTC date and time (formatted according to RFC 3339, Section 5.6) when the road event begins (e.g. 2020-11-03T19:37:00Z)", "type": "string", - "format": "date-time" + "format": "date-time", }, "end_date": { "description": "The UTC date and time (formatted according to RFC 3339, Section 5.6) when the road event ends (e.g. 2020-11-03T19:37:00Z)", "type": "string", - "format": "date-time" + "format": "date-time", }, "is_start_date_verified": { "description": "Indicates if work has been confirmed to have started, such as from a person or field device", - "type": "boolean" + "type": "boolean", }, "is_end_date_verified": { "description": "Indicates if work has been confirmed to have ended, such as from a person or field device", - "type": "boolean" + "type": "boolean", }, "work_zone_type": { "description": "The type of work zone road event", - "$ref": "#/definitions/WorkZoneType" + "$ref": "#/definitions/WorkZoneType", }, "vehicle_impact": { "$ref": "#/definitions/VehicleImpact" @@ -370,63 +305,61 @@ "reduced_speed_limit_kph": { "description": "If applicable, the reduced speed limit posted within the road event, in kilometers per hour", "type": "number", - "minimum": 0 + "minimum": 0, }, "restrictions": { "description": "A list of zero or more restrictions applying to the road event", "type": "array", "items": { "$ref": "#/definitions/Restriction" - } + }, }, "types_of_work": { "description": "A list of the types of work being done in a road event", "type": "array", "items": { "$ref": "#/definitions/TypeOfWork" - } + }, }, "lanes": { "description": "A list of individual lanes within a road event (roadway segment)", "type": "array", - "items": { - "$ref": "#/definitions/Lane" - } + "items": {"$ref": "#/definitions/Lane"}, }, "impacted_cds_curb_zones": { "description": "A list of references to external CDS Curb Zones impacted by the work zone", "type": "array", "items": { "$ref": "#/definitions/CdsCurbZonesReference" - } + }, }, "event_status": { "description": "**DEPRECATED**", - "$ref": "#/definitions/EventStatus" + "$ref": "#/definitions/EventStatus", }, "start_date_accuracy": { "description": "**DEPRECATED** Use is_start_date_verified instead", - "$ref": "#/definitions/TimeVerification" + "$ref": "#/definitions/TimeVerification", }, "end_date_accuracy": { "description": "**DEPRECATED** Use is_end_date_verified instead", - "$ref": "#/definitions/TimeVerification" + "$ref": "#/definitions/TimeVerification", }, "beginning_accuracy": { "description": "**DEPRECATED** Use is_start_position_verified instead.", - "$ref": "#/definitions/SpatialVerification" + "$ref": "#/definitions/SpatialVerification", }, "ending_accuracy": { "description": "**DEPRECATED** Use is_end_position_verified instead.", - "$ref": "#/definitions/SpatialVerification" - } + "$ref": "#/definitions/SpatialVerification", + }, }, "required": [ "core_details", "start_date", "end_date", "vehicle_impact", - "location_method" + "location_method", ], "allOf": [ { @@ -440,7 +373,7 @@ "required": [ "start_date_accuracy" ] - } + }, ] }, { @@ -454,7 +387,7 @@ "required": [ "beginning_accuracy" ] - } + }, ] }, { @@ -464,11 +397,7 @@ "is_end_date_verified" ] }, - { - "required": [ - "end_date_accuracy" - ] - } + {"required": ["end_date_accuracy"]}, ] }, { @@ -478,16 +407,12 @@ "is_end_position_verified" ] }, - { - "required": [ - "ending_accuracy" - ] - } + {"required": ["ending_accuracy"]}, ] - } - ] - } - ] + }, + ], + }, + ], }, "DetourRoadEvent": { "title": "Detour Road Event", @@ -498,18 +423,12 @@ "properties": { "core_details": { "properties": { - "event_type": { - "const": "detour" - } + "event_type": {"const": "detour"} }, - "required": [ - "event_type" - ] + "required": ["event_type"], } }, - "required": [ - "core_details" - ] + "required": ["core_details"], }, { "properties": { @@ -518,57 +437,57 @@ }, "beginning_cross_street": { "description": "Name or number of the nearest cross street along the roadway where the event begins", - "type": "string" + "type": "string", }, "ending_cross_street": { "description": "Name or number of the nearest cross street along the roadway where the event ends", - "type": "string" + "type": "string", }, "beginning_milepost": { "description": "The linear distance measured against a milepost marker along a roadway where the event begins", "type": "number", - "minimum": 0 + "minimum": 0, }, "ending_milepost": { "description": "The linear distance measured against a milepost marker along a roadway where the event ends", "type": "number", - "minimum": 0 + "minimum": 0, }, "start_date": { "description": "The UTC date and time (formatted according to RFC 3339, Section 5.6) when the road event begins (e.g. 2020-11-03T19:37:00Z)", "type": "string", - "format": "date-time" + "format": "date-time", }, "end_date": { "description": "The UTC date and time (formatted according to RFC 3339, Section 5.6) when the road event ends (e.g. 2020-11-03T19:37:00Z)", "type": "string", - "format": "date-time" + "format": "date-time", }, "is_start_date_verified": { "description": "Indicates if the detour has been confirmed to have started, such as from a person or device in the field or a report from a traffic management center", - "type": "boolean" + "type": "boolean", }, "is_end_date_verified": { "description": "Indicates if the detour has been confirmed to have ended, such as from a person or device in the field or a report from a traffic management center", - "type": "boolean" + "type": "boolean", }, "event_status": { "description": "**DEPRECATED**", - "$ref": "#/definitions/EventStatus" + "$ref": "#/definitions/EventStatus", }, "start_date_accuracy": { "description": "**DEPRECATED** Use is_start_date_verified instead", - "$ref": "#/definitions/TimeVerification" + "$ref": "#/definitions/TimeVerification", }, "end_date_accuracy": { "description": "**DEPRECATED** Use is_end_date_verified instead", - "$ref": "#/definitions/TimeVerification" - } + "$ref": "#/definitions/TimeVerification", + }, }, "required": [ "core_details", "start_date", - "end_date" + "end_date", ], "allOf": [ { @@ -582,7 +501,7 @@ "required": [ "start_date_accuracy" ] - } + }, ] }, { @@ -592,16 +511,12 @@ "is_end_date_verified" ] }, - { - "required": [ - "end_date_accuracy" - ] - } + {"required": ["end_date_accuracy"]}, ] - } - ] - } - ] + }, + ], + }, + ], }, "RoadEventCoreDetails": { "title": "Road Event Core Details", @@ -610,25 +525,21 @@ "properties": { "data_source_id": { "description": "Identifies the data source from which the road event data is sourced from", - "type": "string" - }, - "event_type": { - "$ref": "#/definitions/EventType" + "type": "string", }, + "event_type": {"$ref": "#/definitions/EventType"}, "related_road_events": { "description": "A list describing one or more road events which are related to this road event, such as a work zone project it is part of or another road event that occurs before or after it in sequence.", "type": "array", "items": { "$ref": "#/definitions/RelatedRoadEvent" - } + }, }, "road_names": { "description": "A list of publicly known names of the road on which the event occurs. This may include the road number designated by a jurisdiction such as a county, state or interstate (e.g. I-5, VT 133)", "type": "array", "minItems": 1, - "items": { - "type": "string" - } + "items": {"type": "string"}, }, "direction": { "$id": "https://raw.githubusercontent.com/usdot-jpo-ode/wzdx/main/schemas/4.2/Direction.json", @@ -643,38 +554,38 @@ "undefined", "unknown", "inner-loop", - "outer-loop" - ] + "outer-loop", + ], }, "name": { "description": "A human-readable name for the road event", - "type": "string" + "type": "string", }, "description": { "description": "Short free text description of the road event", - "type": "string" + "type": "string", }, "creation_date": { "description": "The UTC date and time (formatted according to RFC 3339, Section 5.6) when the road event was created (e.g. 2020-11-03T19:37:00Z)", "type": "string", - "format": "date-time" + "format": "date-time", }, "update_date": { "description": "The UTC date and time (formatted according to RFC 3339, Section 5.6) when any information in the RoadEventFeature (including child objects) that the RoadEventCoreDetails applies to was most recently updated or confirmed as up to date", "type": "string", - "format": "date-time" + "format": "date-time", }, "relationship": { "description": "**DEPRECATED**", - "$ref": "#/definitions/Relationship" - } + "$ref": "#/definitions/Relationship", + }, }, "required": [ "event_type", "data_source_id", "direction", - "road_names" - ] + "road_names", + ], }, "LocationMethod": { "title": "Location Method Enumerated Type", @@ -684,8 +595,8 @@ "sign-method", "junction-method", "other", - "unknown" - ] + "unknown", + ], }, "Relationship": { "title": "Relationship", @@ -696,35 +607,27 @@ "description": "Indicates the first (can be multiple) road event in a sequence of road events by RoadEventFeature 'id'", "type": "array", "minItems": 1, - "items": { - "type": "string" - } + "items": {"type": "string"}, }, "next": { "description": "Indicates the next (can be multiple) road event in a sequence of road events by RoadEventFeature 'id'", "type": "array", "minItems": 1, - "items": { - "type": "string" - } + "items": {"type": "string"}, }, "parents": { "description": "Indicates entities that the road event with this relationship is a part of, such as a work zone project or phase. Values can but do not have to correspond to a WZDx entity", "type": "array", "minItems": 1, - "items": { - "type": "string" - } + "items": {"type": "string"}, }, "children": { "description": "Indicates entities that are part of the road event with this relationship, such as a detour or piece of equipment. Values can but do not have to correspond to a WZDx entity", "type": "array", "minItems": 1, - "items": { - "type": "string" - } - } - } + "items": {"type": "string"}, + }, + }, }, "RelatedRoadEvent": { "title": "RelatedRoadEvent", @@ -733,34 +636,27 @@ "properties": { "type": { "description": "The type of road event being identified, such as another sequence of related work zones, a detour, or next road event in sequence.", - "$ref": "#/definitions/RelatedRoadEventType" + "$ref": "#/definitions/RelatedRoadEventType", }, "id": { "description": "An identifier for the related road event by the type property.", - "type": "string" - } + "type": "string", + }, }, - "required": [ - "type", - "id" - ] + "required": ["type", "id"], }, "TypeOfWork": { "title": "Type of Work", "description": "A description of the type of work being done in a road event and an indication of if that work will result in an architectural change to the roadway", "type": "object", "properties": { - "type_name": { - "$ref": "#/definitions/WorkTypeName" - }, + "type_name": {"$ref": "#/definitions/WorkTypeName"}, "is_architectural_change": { "description": "A flag indicating whether the type of work will result in an architectural change to the roadway", - "type": "boolean" - } + "type": "boolean", + }, }, - "required": [ - "type_name" - ] + "required": ["type_name"], }, "Lane": { "title": "Lane", @@ -770,56 +666,34 @@ "order": { "description": "The position (index) of the lane in sequence on the roadway, where '1' represents the left-most lane", "type": "integer", - "minimum": 1 - }, - "status": { - "$ref": "#/definitions/LaneStatus" - }, - "type": { - "$ref": "#/definitions/LaneType" + "minimum": 1, }, + "status": {"$ref": "#/definitions/LaneStatus"}, + "type": {"$ref": "#/definitions/LaneType"}, "lane_number": { "description": "***DEPRECATED*** The number assigned to the lane to help identify its position. Flexible, but usually used for regular, drivable lanes", "type": "integer", - "minimum": 1 + "minimum": 1, }, "restrictions": { "description": "A list of zero or more restrictions specific to the lane", "type": "array", - "items": { - "$ref": "#/definitions/Restriction" - } - } + "items": {"$ref": "#/definitions/Restriction"}, + }, }, - "required": [ - "status", - "type", - "order" - ] + "required": ["status", "type", "order"], }, "Restriction": { "title": "Restriction", "description": "A restriction on a roadway or lane, including type and value", "type": "object", "properties": { - "type": { - "$ref": "#/definitions/RestrictionType" - }, - "value": { - "type": "number" - }, - "unit": { - "$ref": "#/definitions/UnitOfMeasurement" - } + "type": {"$ref": "#/definitions/RestrictionType"}, + "value": {"type": "number"}, + "unit": {"$ref": "#/definitions/UnitOfMeasurement"}, }, - "required": [ - "type" - ], - "dependencies": { - "value": [ - "unit" - ] - } + "required": ["type"], + "dependencies": {"value": ["unit"]}, }, "CdsCurbZonesReference": { "title": "CdsCurbZonesReference", @@ -829,20 +703,15 @@ "cds_curb_zone_ids": { "description": "A list of CDS Curb Zone ids", "type": "array", - "items": { - "type": "string" - } + "items": {"type": "string"}, }, "cds_curbs_api_url": { "description": "An identifier for the source of the requested CDS Curbs API", "type": "string", - "format": "uri" - } + "format": "uri", + }, }, - "required": [ - "cds_curb_zone_ids", - "cds_curbs_api_url" - ] + "required": ["cds_curb_zone_ids", "cds_curbs_api_url"], }, "WorkerPresence": { "title": "Worker Presence", @@ -851,7 +720,7 @@ "properties": { "are_workers_present": { "description": "Whether workers are present in the work zone event area, following the definition provided in the \u2018definition\u2019 property on the WorkerPresence object", - "type": "boolean" + "type": "boolean", }, "method": { "$ref": "#/definitions/WorkerPresenceMethod" @@ -859,7 +728,7 @@ "worker_presence_last_confirmed_date": { "description": "The UTC date and time at which the presence of workers was last confirmed", "type": "string", - "format": "date-time" + "format": "date-time", }, "confidence": { "$ref": "#/definitions/WorkerPresenceConfidence" @@ -870,37 +739,25 @@ "items": { "$ref": "#/definitions/WorkerPresenceDefinition" }, - "uniqueItems": True - } + "uniqueItems": True, + }, }, - "required": [ - "are_workers_present" - ] + "required": ["are_workers_present"], }, "EventType": { "title": "Road Event Type Enumerated Type", "description": "The type of WZDx road event", - "enum": [ - "work-zone", - "detour", - "restriction" - ] + "enum": ["work-zone", "detour", "restriction"], }, "SpatialVerification": { "title": "Spatial Verification Enumerated Type", "description": "An indication of how a geographical coordinate was defined", - "enum": [ - "estimated", - "verified" - ] + "enum": ["estimated", "verified"], }, "TimeVerification": { "title": "Time Verification Enumerated Type", "description": "A measure of how accurate a date-time is", - "enum": [ - "estimated", - "verified" - ] + "enum": ["estimated", "verified"], }, "EventStatus": { "title": "Event Status Enumerated Type", @@ -910,17 +767,13 @@ "pending", "active", "completed", - "cancelled" - ] + "cancelled", + ], }, "WorkZoneType": { "title": "Work Zone Type Enumerated Type", "description": "The type of work zone road event", - "enum": [ - "static", - "moving", - "planned-moving-area" - ] + "enum": ["static", "moving", "planned-moving-area"], }, "VehicleImpact": { "title": "Vehicle Impact Enumerated Type", @@ -937,8 +790,8 @@ "some-lanes-closed-split", "flagging", "temporary-traffic-signal", - "unknown" - ] + "unknown", + ], }, "RestrictionType": { "title": "Restriction Type Enumerated Type", @@ -958,8 +811,8 @@ "towing-prohibited", "permitted-oversize-loads-prohibited", "local-access-only", - "no-passing" - ] + "no-passing", + ], }, "WorkTypeName": { "title": "Work Type Name Enumerated Type", @@ -974,8 +827,8 @@ "surface-work", "painting", "roadway-relocation", - "roadway-creation" - ] + "roadway-creation", + ], }, "LaneStatus": { "title": "Lane Status Enumerated Type", @@ -987,8 +840,8 @@ "shift-right", "merge-left", "merge-right", - "alternating-flow" - ] + "alternating-flow", + ], }, "LaneType": { "title": "Lane Type Enumerated Type", @@ -1005,8 +858,8 @@ "parking", "median", "two-way-center-turn-lane", - "center-left-turn-lane" - ] + "center-left-turn-lane", + ], }, "UnitOfMeasurement": { "title": "Unit of Measurement Enumerated Type", @@ -1017,8 +870,8 @@ "centimeters", "pounds", "tons", - "kilograms" - ] + "kilograms", + ], }, "WorkerPresenceMethod": { "title": "Worker Presence Method Enumerated Type", @@ -1032,8 +885,8 @@ "mobile-device-present", "check-in-app", "check-in-verbal", - "scheduled" - ] + "scheduled", + ], }, "WorkerPresenceDefinition": { "title": "Worker Presence Definition Enumerated Type", @@ -1045,17 +898,13 @@ "mobile-equipment-in-work-zone-not-moving", "fixed-equipment-in-work-zone", "humans-behind-barrier", - "humans-in-right-of-way" - ] + "humans-in-right-of-way", + ], }, "WorkerPresenceConfidence": { "title": "Worker Presence Confidence Enumerated Type", "description": "A high-level description of the feed publisher's confidence in the reported WorkerPresence value of are_workers_present", - "enum": [ - "low", - "medium", - "high" - ] + "enum": ["low", "medium", "high"], }, "RelatedRoadEventType": { "title": "Related Road Event Type Enumerated Type", @@ -1068,13 +917,13 @@ "related-work-zone", "related-detour", "planned-moving-operation", - "active-moving-operation" - ] - } - } - } + "active-moving-operation", + ], + }, + }, + }, ] - } + }, }, "bbox": { "$id": "https://raw.githubusercontent.com/usdot-jpo-ode/wzdx/main/schemas/4.2/BoundingBox.json", @@ -1083,9 +932,7 @@ "description": "Information on the coordinate range for a Geometry, Feature, or FeatureCollection", "type": "array", "minItems": 4, - "items": { - "type": "number" - } + "items": {"type": "number"}, }, "road_event_feed_info": { "$id": "https://raw.githubusercontent.com/usdot-jpo-ode/wzdx/main/schemas/4.2/FeedInfo.json", @@ -1096,53 +943,44 @@ "properties": { "publisher": { "description": "The organization responsible for publishing the feed", - "type": "string" + "type": "string", }, "contact_name": { "description": "The name of the individual or group responsible for the data feed", - "type": "string" + "type": "string", }, "contact_email": { "description": "The email address of the individual or group responsible for the data feed", "type": "string", - "format": "email" + "format": "email", }, "update_frequency": { "description": "The frequency in seconds at which the data feed is updated", "type": "integer", - "minimum": 1 + "minimum": 1, }, "update_date": { "description": "The UTC date and time when the GeoJSON file (representing the instance of the feed) was generated", "type": "string", - "format": "date-time" + "format": "date-time", }, "version": { "description": "The WZDx specification version used to create the data feed, in 'major.minor' format", "type": "string", - "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", }, "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/\"", - "enum": [ - "https://creativecommons.org/publicdomain/zero/1.0/" - ] + "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/"', + "enum": ["https://creativecommons.org/publicdomain/zero/1.0/"], }, "data_sources": { "description": "A list of specific data sources for the road event data in the feed", "type": "array", - "items": { - "$ref": "#/definitions/FeedDataSource" - }, - "minItems": 1 - } + "items": {"$ref": "#/definitions/FeedDataSource"}, + "minItems": 1, + }, }, - "required": [ - "update_date", - "version", - "publisher", - "data_sources" - ], + "required": ["update_date", "version", "publisher", "data_sources"], "definitions": { "FeedDataSource": { "title": "WZDx Feed Data Source", @@ -1150,68 +988,51 @@ "type": "object", "properties": { "data_source_id": { - "description": "Unique identifier for the organization providing work zone data. It is recommended that this identifier is a Universally Unique IDentifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", - "type": "string" + "description": "Unique identifier for the organization providing work zone data. It is recommended that this identifier is a Universally Unique Identifier (UUID) as defined in [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).", + "type": "string", }, "organization_name": { "description": "The name of the organization for the authoritative source of the work zone data", - "type": "string" + "type": "string", }, "contact_name": { "description": "The name of the individual or group responsible for the data source", - "type": "string" + "type": "string", }, "contact_email": { "description": "The email address of the individual or group responsible for the data source", "type": "string", - "format": "email" + "format": "email", }, "update_frequency": { "description": "The frequency in seconds at which the data source is updated", "type": "integer", - "minimum": 1 + "minimum": 1, }, "update_date": { "description": "The UTC date and time when the data source was last updated", "type": "string", - "format": "date-time" + "format": "date-time", }, "lrs_type": { "description": "**DEPRECATED** Describes the type of linear referencing system used for the milepost measurements", - "type": "string" + "type": "string", }, "lrs_url": { "description": "**DEPRECATED** A URL where additional information on the LRS information and transformation information is stored", "type": "string", - "format": "uri" + "format": "uri", }, "location_verify_method": { "description": "***DEPRECATED***The method used to verify the accuracy of the location information", - "type": "string" - } + "type": "string", + }, }, - "required": [ - "data_source_id", - "organization_name" - ] + "required": ["data_source_id", "organization_name"], } - } - } - }, - "required": [ - "type", - "features" - ], - "anyOf": [ - { - "required": [ - "feed_info" - ] + }, }, - { - "required": [ - "road_event_feed_info" - ] - } - ] -} \ No newline at end of file + }, + "required": ["type", "features"], + "anyOf": [{"required": ["feed_info"]}, {"required": ["road_event_feed_info"]}], +} From e5b178e2d7297bc08b965c0275cc2d5196c72a9c Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 6 Jan 2026 17:59:05 -0700 Subject: [PATCH 14/14] Adding geojson type descriptions --- wzdx/models/geometry/geojson_linestring.py | 11 +++++++++-- wzdx/models/geometry/geojson_multipoint.py | 11 +++++++++-- wzdx/models/geometry/geojson_point.py | 11 ++++++++--- wzdx/models/geometry/geojson_polygon.py | 11 ++++++++--- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/wzdx/models/geometry/geojson_linestring.py b/wzdx/models/geometry/geojson_linestring.py index d9dceb6..34ef5e2 100644 --- a/wzdx/models/geometry/geojson_linestring.py +++ b/wzdx/models/geometry/geojson_linestring.py @@ -5,5 +5,12 @@ class GeoJsonLineString(BaseModel): """GeoJSON LineString geometry""" - type: Literal["LineString"] = Field(default="LineString", alias="type") - coordinates: list[list[float]] = Field(alias="coordinates") + type: Literal["LineString"] = Field( + default="LineString", + alias="type", + description="The GeoJSON object type. This MUST be the string LineString.", + ) + coordinates: list[list[float]] = Field( + alias="coordinates", + description="A list of two or more positions that make up the LineString.", + ) diff --git a/wzdx/models/geometry/geojson_multipoint.py b/wzdx/models/geometry/geojson_multipoint.py index c6f2f53..644bd6a 100644 --- a/wzdx/models/geometry/geojson_multipoint.py +++ b/wzdx/models/geometry/geojson_multipoint.py @@ -5,5 +5,12 @@ class GeoJsonMultiPoint(BaseModel): """GeoJSON MultiPoint geometry""" - type: Literal["MultiPoint"] = Field(default="MultiPoint", alias="type") - coordinates: list[list[float]] = Field(alias="coordinates") + type: Literal["MultiPoint"] = Field( + default="MultiPoint", + alias="type", + description="The GeoJSON object type. This MUST be the string MultiPoint.", + ) + coordinates: list[list[float]] = Field( + alias="coordinates", + description="A list of positions that make up the MultiPoint.", + ) diff --git a/wzdx/models/geometry/geojson_point.py b/wzdx/models/geometry/geojson_point.py index 517b43f..661f19e 100644 --- a/wzdx/models/geometry/geojson_point.py +++ b/wzdx/models/geometry/geojson_point.py @@ -5,7 +5,12 @@ class GeoJsonPoint(BaseModel): """GeoJSON Point geometry""" - type: Literal["Point"] = Field(default="Point", alias="type") + type: Literal["Point"] = Field( + default="Point", + alias="type", + description="The GeoJSON object type. This MUST be the string Point.", + ) coordinates: list[float] = Field( - alias="coordinates" - ) # [longitude, latitude] or [longitude, latitude, elevation] + alias="coordinates", + description="A single position that makes up the Point.", + ) diff --git a/wzdx/models/geometry/geojson_polygon.py b/wzdx/models/geometry/geojson_polygon.py index 6f77523..b13fe4e 100644 --- a/wzdx/models/geometry/geojson_polygon.py +++ b/wzdx/models/geometry/geojson_polygon.py @@ -5,7 +5,12 @@ class GeoJsonPolygon(BaseModel): """GeoJSON Polygon geometry""" - type: Literal["Polygon"] = Field(default="Polygon", alias="type") + type: Literal["Polygon"] = Field( + default="Polygon", + alias="type", + description="The GeoJSON object type. This MUST be the string Polygon.", + ) coordinates: list[list[list[float]]] = Field( - alias="coordinates" - ) # Array of linear rings + alias="coordinates", + description="A list of linear rings that make up the Polygon.", + )