Repository: clairBuoyant/pybuoy (language: python)

Issue:
Get wave and wind forecasts from NOAA
Currently pybuoy as it stands is only capable of pulling current and historical data for meteorological and wave conditions. This feature seeks to implement a forecast class which would be responsible for making calls to a NOAA database that provides forecast wind speed, direction, gust, and wave height data for specific geographical points.

Acceptance Criteria:
- Forecast class that is solely responsible for making calls to the NOAA NDFD to retrieve forecast surf data
- Ability to parse NDFD XML data 
- Present parsed data as an Observations class or even Forecasts class for clients to be able to work with

Additional Info:
Potentially refactor code base to support more flexible and robust code by creating base classes for observation and forecasts. Up for debate. 


Discussion / hints:
Adding link to documentation for how NDFD's DWML works and is structured:
https://graphical.weather.gov/xml/mdl/XML/Design/MDL_XML_Design.pdf

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4e3dbc4..5159181 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,19 +4,19 @@ repos:
     hooks:
       - id: branch-validate
   - repo: https://github.com/commitizen-tools/commitizen
-    rev: v2.32.2
+    rev: v2.42.1
     hooks:
       - id: commitizen
         stages: [commit-msg]
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.3.0
+    rev: v4.4.0
     hooks:
       - id: check-added-large-files
       - id: check-merge-conflict
       - id: end-of-file-fixer
       - id: trailing-whitespace
   - repo: https://github.com/Lucas-C/pre-commit-hooks
-    rev: v1.3.1
+    rev: v1.4.2
     hooks:
       - id: forbid-tabs
       - id: remove-tabs
@@ -25,17 +25,17 @@ repos:
     hooks:
       - id: absolufy-imports
   - repo: https://github.com/psf/black
-    rev: 22.6.0
+    rev: 23.1.0
     hooks:
       - id: black
   - repo: https://github.com/PyCQA/isort
-    rev: 5.10.1
+    rev: 5.11.5
     hooks:
       - id: isort
   - repo: https://github.com/PyCQA/flake8
-    rev: 5.0.4
+    rev: 6.0.0
     hooks:
       - id: flake8
         additional_dependencies:
-          - flake8-black>=0.3.3
+          - flake8-black>=0.3.6
         args: ['--config=.flake8']
diff --git a/CHANGES.rst b/CHANGES.rst
index be920b9..ad9cf68 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,21 @@
 Change Log
 ==========
 
+Unreleased
+----------
+
+Buoy
+~~~~
+
+- **Forecasts**: ``get`` returns an iterable ``ForecastObservations`` object. The type of
+  iterable provided is ``ForecastObservation``.
+
+Internal
+~~~~~~~~
+
+- Bump certifi to 2022.12.07. (CVE-2022-23491)
+- Update all dependencies to latest.
+
 0.4.3 (2022-09-19)
 ------------------
 
diff --git a/README.rst b/README.rst
index 652d47f..57c552f 100644
--- a/README.rst
+++ b/README.rst
@@ -1,5 +1,6 @@
-pybuoy - Python NDBC API Wrapper
-================================
+=====================================
+pybuoy - Python NOAA/NDBC API Wrapper
+=====================================
 
 .. image:: https://img.shields.io/pypi/v/pybuoy?color=blue
     :alt: Latest Version
@@ -14,7 +15,10 @@ pybuoy - Python NDBC API Wrapper
     :target: https://pypi.python.org/pypi/pybuoy
 
 
-``pybuoy`` is a server-side Python package that was built to facilitate rapid discovery of new data from `NDBC <https://www.ndbc.noaa.gov>`_ with only a single dependency!
+---------------------------------------------------
+``pybuoy`` is a Python wrapper for `NOAA <https://www.noaa.gov>`_ and `NDBC <https://www.ndbc.noaa.gov>`_ REST web services.
+---------------------------------------------------
+
 
 Installation
 ------------
@@ -39,10 +43,12 @@ Quickstart
     buoy = Buoy()
 
 
-With the ``buoy`` instance you can then interact with NDBC:
+With a instance of ``Buoy``, you can request data from NOAA and NDBC like so:
 
 - `Get all active stations <https://pybuoy.readthedocs.io/en/latest/tutorials/active_buoys.html>`_.
 
 - `Get realtime meteorological data <https://pybuoy.readthedocs.io/en/latest/tutorials/realtime_data.html#get-meteorological-data>`_ for buoy by station_id.
 
 - `Get realtime wave summary data <https://pybuoy.readthedocs.io/en/latest/tutorials/realtime_data.html#get-wave-summary-data>`_ for buoy by station_id.
+
+- `Get forecast data <https://pybuoy.readthedocs.io/en/latest/tutorials/forecast_data.html>`_ for buoy by lat/lon.
diff --git a/docs/code_overview/exceptions.rst b/docs/code_overview/exceptions.rst
index bc63cbd..b7f1103 100644
--- a/docs/code_overview/exceptions.rst
+++ b/docs/code_overview/exceptions.rst
@@ -5,5 +5,11 @@ Exceptions
 pybuoy.exceptions
 -----------------
 
-.. autoclass:: pybuoy.exceptions.BuoyException
-    :inherited-members:
+.. autoclass:: pybuoy.exceptions.HTTPException
+    :inherited-members: pybuoy.exceptions.BaseException
+.. autoclass:: pybuoy.exceptions.NDBCException
+    :inherited-members: pybuoy.exceptions.BaseException
+.. autoclass:: pybuoy.exceptions.NOAAException
+    :inherited-members: pybuoy.exceptions.BaseException
+.. autoclass:: pybuoy.exceptions.DWMLException
+    :inherited-members: pybuoy.exceptions.NOAAException
diff --git a/docs/examples/get_forecast_data.py b/docs/examples/get_forecast_data.py
new file mode 100644
index 0000000..5e374c9
--- /dev/null
+++ b/docs/examples/get_forecast_data.py
@@ -0,0 +1,39 @@
+from datetime import datetime, timedelta
+
+from pybuoy import Buoy
+
+start = datetime.today().isoformat()
+end = (datetime.today() + timedelta(7)).isoformat()
+buoy = Buoy()
+
+station_44065 = [40.369, -73.703]  # lat, lon
+
+forecast_observations = buoy.forecasts.get(
+    lat=station_44065[0], lon=station_44065[1], start_date=start, end_date=end
+)
+
+for observation in forecast_observations:
+    # IDE should now detect available attributes
+    # add code below here
+    pass
+
+# Sample Output of Forecast Data
+print(f"Station Forecast: {forecast_observations[0]}")
+# OUTPUT: Station Forecast: Observation(2022-12-04 19:00:00) # noqa: E501,W505
+#
+"""forecast_data returns an instance of `ForecastObservations`.
+`Observations` is an iterable object and contains a list
+of `ForecastObservation`.
+
+Under the hood, the aforementioned sample output provides this object:
+
+    ForecastObservation(
+        datetime=datetime.datetime(2022, 12, 4, 19, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400))),
+        wind_direction=ObservationDatum(Wind Direction: 280.0 degrees),
+        wind_speed=ObservationDatum(Wind Speed: 9.0 knots),
+        wind_gust=ObservationDatum(Wind Gust: 11.0 knots),
+        wave_height=ObservationDatum(Wave Height: 2.0 feet)
+    )
+
+`forecast_data[0].wave_height.value` would return 2.0 as type float (defaults to None, if no value was recorded).
+"""  # noqa: E501,W505
diff --git a/docs/index.rst b/docs/index.rst
index 865920b..3b8ea35 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,5 +1,10 @@
-pybuoy: Python Wrapper for NDBC/NOAA buoy data
-==============================================
+======
+pybuoy
+======
+
+---------------------------------------------------
+Python wrapper for `NOAA <https://www.noaa.gov>`_ and `NDBC <https://www.ndbc.noaa.gov>`_ REST web services.
+---------------------------------------------------
 
 .. note::
 
@@ -40,6 +45,7 @@ Contents
     :caption: Tutorials
 
     tutorials/active_buoys.rst
+    tutorials/forecast_data.rst
     tutorials/realtime_data.rst
 
 .. _package_info:
diff --git a/docs/requirements.txt b/docs/requirements.txt
index a9121f7..d47bad5 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,2 +1,2 @@
-Sphinx===5.1.1
-furo===2022.9.15
+Sphinx===6.1.3
+furo===2022.12.7
diff --git a/docs/tutorials/forecast_data.rst b/docs/tutorials/forecast_data.rst
new file mode 100644
index 0000000..b0d2b9d
--- /dev/null
+++ b/docs/tutorials/forecast_data.rst
@@ -0,0 +1,94 @@
+Forecast
+========
+
+Forecast information is provided by NOAA's `National Digital Forecast Database <https://www.ncei.noaa.gov/products/weather-climate-models/national-digital-forecast-database>`_.
+
+As always, you need to begin by creating an instance of :class:`.Buoy`:
+
+.. code-block:: python
+
+    from pybuoy import Buoy
+
+    buoy = Buoy()
+
+.. _get_forecast:
+
+Get Forecast Data
+-----------------
+
+.. code-block:: python
+
+    from datetime import datetime, timedelta
+
+    start = datetime.today().isoformat()
+    end = (datetime.today() + timedelta(7)).isoformat()
+
+    station_44065 = [40.369, -73.703] # lat, lon
+
+    forecast_data = buoy.forecasts.get(
+        lat=station_44065[0],
+        lon=station_44065[1],
+        start_date=start,
+        end_date=end
+    )
+
+.. note::
+
+    start_date and end_date **MUST BE** a valid ISO Date string.
+
+`forecast_data` returns an iterable instance of ``ForecastObservations``.
+
+.. code-block:: python
+
+    ForecastObservations(
+        reports=[
+            ForecastObservation(
+                datetime=datetime.datetime(2022, 12, 4, 19, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400))),
+                wind_direction=ObservationDatum(Wind Direction: 280.0 degrees),
+                wind_speed=ObservationDatum(Wind Speed: 9.0 knots),
+                wind_gust=ObservationDatum(Wind Gust: 11.0 knots),
+                wave_height=ObservationDatum(Wave Height: 2.0 feet)
+            ),
+            ForecastObservation(
+                datetime=datetime.datetime(2022, 12, 4, 22, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400))),
+                wind_direction=ObservationDatum(Wind Direction: 270.0 degrees),
+                wind_speed=ObservationDatum(Wind Speed: 9.0 knots),
+                wind_gust=ObservationDatum(Wind Gust: 12.0 knots),
+                wave_height=ObservationDatum(Wave Height: 2.0 feet)
+            ),
+            ForecastObservation(
+                datetime=datetime.datetime(2022, 12, 5, 1, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400))),
+                wind_direction=ObservationDatum(Wind Direction: 270.0 degrees),
+                wind_speed=ObservationDatum(Wind Speed: 9.0 knots),
+                wind_gust=ObservationDatum(Wind Gust: 12.0 knots),
+                wave_height=ObservationDatum(Wave Height: 2.0 feet)
+            ),
+            ...
+            ForecastObservation(
+                datetime=datetime.datetime(2022, 12, 11, 7, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400))),
+                wind_direction=ObservationDatum(Wind Direction: 20.0 degrees),
+                wind_speed=ObservationDatum(Wind Speed: 16.0 knots),
+                wind_gust=ObservationDatum(Wind Gust: nan knots),
+                wave_height=ObservationDatum(Wave Height: nan feet)
+            ),
+            ForecastObservation(
+                datetime=datetime.datetime(2022, 12, 11, 13, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400))),
+                wind_direction=ObservationDatum(Wind Direction: 30.0 degrees),
+                wind_speed=ObservationDatum(Wind Speed: 13.0 knots),
+                wind_gust=ObservationDatum(Wind Gust: nan knots),
+                wave_height=ObservationDatum(Wave Height: nan feet)
+            ),
+            ForecastObservation(
+                datetime=datetime.datetime(2022, 12, 11, 19, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400))),
+                wind_direction=ObservationDatum(Wind Direction: 50.0 degrees),
+                wind_speed=ObservationDatum(Wind Speed: 13.0 knots),
+                wind_gust=ObservationDatum(Wind Gust: nan knots),
+                wave_height=ObservationDatum(Wave Height: nan feet)
+            )
+        ],
+        size=41
+    )
+
+.. note::
+
+    If no value was recorded (e.g., “Wave Height: nan ft”), it is set to None.
diff --git a/poetry.lock b/poetry.lock
index b1204b1..d5d55cf 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -67,32 +67,45 @@ lxml = ["lxml"]
 
 [[package]]
 name = "black"
-version = "22.12.0"
+version = "23.1.0"
 description = "The uncompromising code formatter."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"},
-    {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"},
-    {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"},
-    {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"},
-    {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"},
-    {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"},
-    {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"},
-    {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"},
-    {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"},
-    {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"},
-    {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"},
-    {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"},
+    {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"},
+    {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"},
+    {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"},
+    {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"},
+    {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"},
+    {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"},
+    {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"},
+    {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"},
+    {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"},
+    {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"},
+    {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"},
+    {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"},
+    {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"},
+    {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"},
+    {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"},
+    {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"},
+    {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"},
+    {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"},
+    {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"},
+    {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"},
+    {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"},
+    {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"},
+    {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"},
+    {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"},
+    {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"},
 ]
 
 [package.dependencies]
 click = ">=8.0.0"
 mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
 pathspec = ">=0.9.0"
 platformdirs = ">=2"
-tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
 
 [package.extras]
 colorama = ["colorama (>=0.4.3)"]
@@ -273,21 +286,6 @@ files = [
     {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"},
 ]
 
-[[package]]
-name = "exceptiongroup"
-version = "1.1.0"
-description = "Backport of PEP 654 (exception groups)"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
-    {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
-]
-
-[package.extras]
-test = ["pytest (>=6)"]
-
 [[package]]
 name = "filelock"
 version = "3.9.0"
@@ -341,14 +339,14 @@ sphinx-basic-ng = "*"
 
 [[package]]
 name = "identify"
-version = "2.5.17"
+version = "2.5.18"
 description = "File identification library for Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"},
-    {file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"},
+    {file = "identify-2.5.18-py2.py3-none-any.whl", hash = "sha256:93aac7ecf2f6abf879b8f29a8002d3c6de7086b8c28d88e1ad15045a15ab63f9"},
+    {file = "identify-2.5.18.tar.gz", hash = "sha256:89e144fa560cc4cffb6ef2ab5e9fb18ed9f9b3cb054384bab4b95c12f6c309fe"},
 ]
 
 [package.extras]
@@ -426,6 +424,99 @@ MarkupSafe = ">=2.0"
 [package.extras]
 i18n = ["Babel (>=2.7)"]
 
+[[package]]
+name = "lxml"
+version = "4.9.2"
+description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
+files = [
+    {file = "lxml-4.9.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2"},
+    {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892"},
+    {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a"},
+    {file = "lxml-4.9.2-cp27-cp27m-win32.whl", hash = "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de"},
+    {file = "lxml-4.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3"},
+    {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50"},
+    {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975"},
+    {file = "lxml-4.9.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c"},
+    {file = "lxml-4.9.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a"},
+    {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4"},
+    {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4"},
+    {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7"},
+    {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184"},
+    {file = "lxml-4.9.2-cp310-cp310-win32.whl", hash = "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda"},
+    {file = "lxml-4.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab"},
+    {file = "lxml-4.9.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9"},
+    {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf"},
+    {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380"},
+    {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92"},
+    {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1"},
+    {file = "lxml-4.9.2-cp311-cp311-win32.whl", hash = "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33"},
+    {file = "lxml-4.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd"},
+    {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0"},
+    {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e"},
+    {file = "lxml-4.9.2-cp35-cp35m-win32.whl", hash = "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df"},
+    {file = "lxml-4.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5"},
+    {file = "lxml-4.9.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53"},
+    {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7"},
+    {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe"},
+    {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c"},
+    {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1"},
+    {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e"},
+    {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74"},
+    {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38"},
+    {file = "lxml-4.9.2-cp36-cp36m-win32.whl", hash = "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5"},
+    {file = "lxml-4.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3"},
+    {file = "lxml-4.9.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03"},
+    {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941"},
+    {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726"},
+    {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b"},
+    {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894"},
+    {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45"},
+    {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e"},
+    {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b"},
+    {file = "lxml-4.9.2-cp37-cp37m-win32.whl", hash = "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe"},
+    {file = "lxml-4.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9"},
+    {file = "lxml-4.9.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8"},
+    {file = "lxml-4.9.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24"},
+    {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889"},
+    {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f"},
+    {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03"},
+    {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c"},
+    {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f"},
+    {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457"},
+    {file = "lxml-4.9.2-cp38-cp38-win32.whl", hash = "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b"},
+    {file = "lxml-4.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7"},
+    {file = "lxml-4.9.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1"},
+    {file = "lxml-4.9.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140"},
+    {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4"},
+    {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf"},
+    {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947"},
+    {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5"},
+    {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5"},
+    {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2"},
+    {file = "lxml-4.9.2-cp39-cp39-win32.whl", hash = "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1"},
+    {file = "lxml-4.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f"},
+    {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c"},
+    {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a"},
+    {file = "lxml-4.9.2-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419"},
+    {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05"},
+    {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f"},
+    {file = "lxml-4.9.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9"},
+    {file = "lxml-4.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5"},
+    {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746"},
+    {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7"},
+    {file = "lxml-4.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409"},
+    {file = "lxml-4.9.2.tar.gz", hash = "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67"},
+]
+
+[package.extras]
+cssselect = ["cssselect (>=0.7)"]
+html5 = ["html5lib"]
+htmlsoup = ["BeautifulSoup4"]
+source = ["Cython (>=0.29.7)"]
+
 [[package]]
 name = "markupsafe"
 version = "2.1.2"
@@ -500,43 +591,42 @@ files = [
 
 [[package]]
 name = "mypy"
-version = "1.0.0"
+version = "1.0.1"
 description = "Optional static typing for Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "mypy-1.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0626db16705ab9f7fa6c249c017c887baf20738ce7f9129da162bb3075fc1af"},
-    {file = "mypy-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ace23f6bb4aec4604b86c4843276e8fa548d667dbbd0cb83a3ae14b18b2db6c"},
-    {file = "mypy-1.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87edfaf344c9401942883fad030909116aa77b0fa7e6e8e1c5407e14549afe9a"},
-    {file = "mypy-1.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0ab090d9240d6b4e99e1fa998c2d0aa5b29fc0fb06bd30e7ad6183c95fa07593"},
-    {file = "mypy-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:7cc2c01dfc5a3cbddfa6c13f530ef3b95292f926329929001d45e124342cd6b7"},
-    {file = "mypy-1.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14d776869a3e6c89c17eb943100f7868f677703c8a4e00b3803918f86aafbc52"},
-    {file = "mypy-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb2782a036d9eb6b5a6efcdda0986774bf798beef86a62da86cb73e2a10b423d"},
-    {file = "mypy-1.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cfca124f0ac6707747544c127880893ad72a656e136adc935c8600740b21ff5"},
-    {file = "mypy-1.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8845125d0b7c57838a10fd8925b0f5f709d0e08568ce587cc862aacce453e3dd"},
-    {file = "mypy-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b1b9e1ed40544ef486fa8ac022232ccc57109f379611633ede8e71630d07d2"},
-    {file = "mypy-1.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c7cf862aef988b5fbaa17764ad1d21b4831436701c7d2b653156a9497d92c83c"},
-    {file = "mypy-1.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd187d92b6939617f1168a4fe68f68add749902c010e66fe574c165c742ed88"},
-    {file = "mypy-1.0.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4e5175026618c178dfba6188228b845b64131034ab3ba52acaffa8f6c361f805"},
-    {file = "mypy-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2f6ac8c87e046dc18c7d1d7f6653a66787a4555085b056fe2d599f1f1a2a2d21"},
-    {file = "mypy-1.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7306edca1c6f1b5fa0bc9aa645e6ac8393014fa82d0fa180d0ebc990ebe15964"},
-    {file = "mypy-1.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3cfad08f16a9c6611e6143485a93de0e1e13f48cfb90bcad7d5fde1c0cec3d36"},
-    {file = "mypy-1.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67cced7f15654710386e5c10b96608f1ee3d5c94ca1da5a2aad5889793a824c1"},
-    {file = "mypy-1.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a86b794e8a56ada65c573183756eac8ac5b8d3d59daf9d5ebd72ecdbb7867a43"},
-    {file = "mypy-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:50979d5efff8d4135d9db293c6cb2c42260e70fb010cbc697b1311a4d7a39ddb"},
-    {file = "mypy-1.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ae4c7a99e5153496243146a3baf33b9beff714464ca386b5f62daad601d87af"},
-    {file = "mypy-1.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e398652d005a198a7f3c132426b33c6b85d98aa7dc852137a2a3be8890c4072"},
-    {file = "mypy-1.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be78077064d016bc1b639c2cbcc5be945b47b4261a4f4b7d8923f6c69c5c9457"},
-    {file = "mypy-1.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92024447a339400ea00ac228369cd242e988dd775640755fa4ac0c126e49bb74"},
-    {file = "mypy-1.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:fe523fcbd52c05040c7bee370d66fee8373c5972171e4fbc323153433198592d"},
-    {file = "mypy-1.0.0-py3-none-any.whl", hash = "sha256:2efa963bdddb27cb4a0d42545cd137a8d2b883bd181bbc4525b568ef6eca258f"},
-    {file = "mypy-1.0.0.tar.gz", hash = "sha256:f34495079c8d9da05b183f9f7daec2878280c2ad7cc81da686ef0b484cea2ecf"},
+    {file = "mypy-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71a808334d3f41ef011faa5a5cd8153606df5fc0b56de5b2e89566c8093a0c9a"},
+    {file = "mypy-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:920169f0184215eef19294fa86ea49ffd4635dedfdea2b57e45cb4ee85d5ccaf"},
+    {file = "mypy-1.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27a0f74a298769d9fdc8498fcb4f2beb86f0564bcdb1a37b58cbbe78e55cf8c0"},
+    {file = "mypy-1.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:65b122a993d9c81ea0bfde7689b3365318a88bde952e4dfa1b3a8b4ac05d168b"},
+    {file = "mypy-1.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5deb252fd42a77add936b463033a59b8e48eb2eaec2976d76b6878d031933fe4"},
+    {file = "mypy-1.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2013226d17f20468f34feddd6aae4635a55f79626549099354ce641bc7d40262"},
+    {file = "mypy-1.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:48525aec92b47baed9b3380371ab8ab6e63a5aab317347dfe9e55e02aaad22e8"},
+    {file = "mypy-1.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c96b8a0c019fe29040d520d9257d8c8f122a7343a8307bf8d6d4a43f5c5bfcc8"},
+    {file = "mypy-1.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:448de661536d270ce04f2d7dddaa49b2fdba6e3bd8a83212164d4174ff43aa65"},
+    {file = "mypy-1.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:d42a98e76070a365a1d1c220fcac8aa4ada12ae0db679cb4d910fabefc88b994"},
+    {file = "mypy-1.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e64f48c6176e243ad015e995de05af7f22bbe370dbb5b32bd6988438ec873919"},
+    {file = "mypy-1.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fdd63e4f50e3538617887e9aee91855368d9fc1dea30da743837b0df7373bc4"},
+    {file = "mypy-1.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbeb24514c4acbc78d205f85dd0e800f34062efcc1f4a4857c57e4b4b8712bff"},
+    {file = "mypy-1.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a2948c40a7dd46c1c33765718936669dc1f628f134013b02ff5ac6c7ef6942bf"},
+    {file = "mypy-1.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5bc8d6bd3b274dd3846597855d96d38d947aedba18776aa998a8d46fabdaed76"},
+    {file = "mypy-1.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:17455cda53eeee0a4adb6371a21dd3dbf465897de82843751cf822605d152c8c"},
+    {file = "mypy-1.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e831662208055b006eef68392a768ff83596035ffd6d846786578ba1714ba8f6"},
+    {file = "mypy-1.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e60d0b09f62ae97a94605c3f73fd952395286cf3e3b9e7b97f60b01ddfbbda88"},
+    {file = "mypy-1.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:0af4f0e20706aadf4e6f8f8dc5ab739089146b83fd53cb4a7e0e850ef3de0bb6"},
+    {file = "mypy-1.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:24189f23dc66f83b839bd1cce2dfc356020dfc9a8bae03978477b15be61b062e"},
+    {file = "mypy-1.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93a85495fb13dc484251b4c1fd7a5ac370cd0d812bbfc3b39c1bafefe95275d5"},
+    {file = "mypy-1.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f546ac34093c6ce33f6278f7c88f0f147a4849386d3bf3ae193702f4fe31407"},
+    {file = "mypy-1.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c6c2ccb7af7154673c591189c3687b013122c5a891bb5651eca3db8e6c6c55bd"},
+    {file = "mypy-1.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:15b5a824b58c7c822c51bc66308e759243c32631896743f030daf449fe3677f3"},
+    {file = "mypy-1.0.1-py3-none-any.whl", hash = "sha256:eda5c8b9949ed411ff752b9a01adda31afe7eae1e53e946dbdf9db23865e66c4"},
+    {file = "mypy-1.0.1.tar.gz", hash = "sha256:28cea5a6392bb43d266782983b5a4216c25544cd7d80be681a155ddcdafd152d"},
 ]
 
 [package.dependencies]
 mypy-extensions = ">=0.4.3"
-tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
 typing-extensions = ">=3.10"
 
 [package.extras]
@@ -598,19 +688,19 @@ files = [
 
 [[package]]
 name = "platformdirs"
-version = "2.6.2"
+version = "3.0.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"},
-    {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"},
+    {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"},
+    {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"},
 ]
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
+docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
 
 [[package]]
 name = "pluggy"
@@ -630,14 +720,14 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "pre-commit"
-version = "3.0.4"
+version = "3.1.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 category = "dev"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pre_commit-3.0.4-py2.py3-none-any.whl", hash = "sha256:9e3255edb0c9e7fe9b4f328cb3dc86069f8fdc38026f1bf521018a05eaf4d67b"},
-    {file = "pre_commit-3.0.4.tar.gz", hash = "sha256:bc4687478d55578c4ac37272fe96df66f73d9b5cf81be6f28627d4e712e752d5"},
+    {file = "pre_commit-3.1.0-py2.py3-none-any.whl", hash = "sha256:7001dfcd174540658822b1fd3630ceadf4f41375a5d1844b5c3b3830f227348c"},
+    {file = "pre_commit-3.1.0.tar.gz", hash = "sha256:61bd9f1b96d3d1e763f2a9a0f8522aed341646800642ff6803c73fac5781f5b7"},
 ]
 
 [package.dependencies]
@@ -701,11 +791,9 @@ files = [
 [package.dependencies]
 attrs = ">=19.2.0"
 colorama = {version = "*", markers = "sys_platform == \"win32\""}
-exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
 iniconfig = "*"
 packaging = "*"
 pluggy = ">=0.12,<2.0"
-tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
 testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
@@ -789,14 +877,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "setuptools"
-version = "67.1.0"
+version = "67.4.0"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "setuptools-67.1.0-py3-none-any.whl", hash = "sha256:a7687c12b444eaac951ea87a9627c4f904ac757e7abdc5aac32833234af90378"},
-    {file = "setuptools-67.1.0.tar.gz", hash = "sha256:e261cdf010c11a41cb5cb5f1bf3338a7433832029f559a6a7614bd42a967c300"},
+    {file = "setuptools-67.4.0-py3-none-any.whl", hash = "sha256:f106dee1b506dee5102cc3f3e9e68137bbad6d47b616be7991714b0c62204251"},
+    {file = "setuptools-67.4.0.tar.gz", hash = "sha256:e5fd0a713141a4a105412233c63dc4e17ba0090c8e8334594ac790ec97792330"},
 ]
 
 [package.extras]
@@ -818,14 +906,14 @@ files = [
 
 [[package]]
 name = "soupsieve"
-version = "2.3.2.post1"
+version = "2.4"
 description = "A modern CSS selector implementation for Beautiful Soup."
 category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 files = [
-    {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"},
-    {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"},
+    {file = "soupsieve-2.4-py3-none-any.whl", hash = "sha256:49e5368c2cda80ee7e84da9dbe3e110b70a4575f196efb74e51b94549d921955"},
+    {file = "soupsieve-2.4.tar.gz", hash = "sha256:e28dba9ca6c7c00173e34e4ba57448f0688bb681b7c5e8bf4971daafc093d69a"},
 ]
 
 [[package]]
@@ -976,28 +1064,16 @@ files = [
 lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
-[[package]]
-name = "tomli"
-version = "2.0.1"
-description = "A lil' TOML parser"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
-    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
-]
-
 [[package]]
 name = "types-requests"
-version = "2.28.11.10"
+version = "2.28.11.15"
 description = "Typing stubs for requests"
 category = "dev"
 optional = false
 python-versions = "*"
 files = [
-    {file = "types-requests-2.28.11.10.tar.gz", hash = "sha256:0279661f4f4d8721d7d5c47fb5053b83d39cf105c7d9f210a8ce9b6a0c312de4"},
-    {file = "types_requests-2.28.11.10-py3-none-any.whl", hash = "sha256:d2083d9245dd6463fc6ea9eb43ccc719c6922ae33e7debcdacb498f7ca84033d"},
+    {file = "types-requests-2.28.11.15.tar.gz", hash = "sha256:fc8eaa09cc014699c6b63c60c2e3add0c8b09a410c818b5ac6e65f92a26dde09"},
+    {file = "types_requests-2.28.11.15-py3-none-any.whl", hash = "sha256:a05e4c7bc967518fba5789c341ea8b0c942776ee474c7873129a61161978e586"},
 ]
 
 [package.dependencies]
@@ -1005,26 +1081,26 @@ types-urllib3 = "<1.27"
 
 [[package]]
 name = "types-urllib3"
-version = "1.26.25.4"
+version = "1.26.25.8"
 description = "Typing stubs for urllib3"
 category = "dev"
 optional = false
 python-versions = "*"
 files = [
-    {file = "types-urllib3-1.26.25.4.tar.gz", hash = "sha256:eec5556428eec862b1ac578fb69aab3877995a99ffec9e5a12cf7fbd0cc9daee"},
-    {file = "types_urllib3-1.26.25.4-py3-none-any.whl", hash = "sha256:ed6b9e8a8be488796f72306889a06a3fc3cb1aa99af02ab8afb50144d7317e49"},
+    {file = "types-urllib3-1.26.25.8.tar.gz", hash = "sha256:ecf43c42d8ee439d732a1110b4901e9017a79a38daca26f08e42c8460069392c"},
+    {file = "types_urllib3-1.26.25.8-py3-none-any.whl", hash = "sha256:95ea847fbf0bf675f50c8ae19a665baedcf07e6b4641662c4c3c72e7b2edf1a9"},
 ]
 
 [[package]]
 name = "typing-extensions"
-version = "4.4.0"
+version = "4.5.0"
 description = "Backported and Experimental Type Hints for Python 3.7+"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
-    {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
+    {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"},
+    {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
 ]
 
 [[package]]
@@ -1046,20 +1122,20 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.18.0"
+version = "20.19.0"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "virtualenv-20.18.0-py3-none-any.whl", hash = "sha256:9d61e4ec8d2c0345dab329fb825eb05579043766a4b26a2f66b28948de68c722"},
-    {file = "virtualenv-20.18.0.tar.gz", hash = "sha256:f262457a4d7298a6b733b920a196bf8b46c8af15bf1fd9da7142995eff15118e"},
+    {file = "virtualenv-20.19.0-py3-none-any.whl", hash = "sha256:54eb59e7352b573aa04d53f80fc9736ed0ad5143af445a1e539aada6eb947dd1"},
+    {file = "virtualenv-20.19.0.tar.gz", hash = "sha256:37a640ba82ed40b226599c522d411e4be5edb339a0c0de030c0dc7b646d61590"},
 ]
 
 [package.dependencies]
 distlib = ">=0.3.6,<1"
 filelock = ">=3.4.1,<4"
-platformdirs = ">=2.4,<3"
+platformdirs = ">=2.4,<4"
 
 [package.extras]
 docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
@@ -1067,5 +1143,5 @@ test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess
 
 [metadata]
 lock-version = "2.0"
-python-versions = "^3.10"
-content-hash = "017313db6e4b59ec29083519c25c3bb3c09b7e496c588617ff08cd138320320f"
+python-versions = "^3.11"
+content-hash = "7c0d5c13535e7156a627fe463904529b87582780fa6e719aaa71d3248ab9923c"
diff --git a/pybuoy/api/base.py b/pybuoy/api/base.py
index 72a3e32..1332c57 100644
--- a/pybuoy/api/base.py
+++ b/pybuoy/api/base.py
@@ -4,8 +4,8 @@
 
 
 class ApiBase(ParserMixin):
-    def make_request(self, url: str):
-        response = requests.get(url)
+    def make_request(self, url: str, params: dict = {}):
+        response = requests.get(url, params=params)
         if response.status_code == 200:
             return response.text
         elif response.status_code == 404:
diff --git a/pybuoy/api/forecasts.py b/pybuoy/api/forecasts.py
new file mode 100644
index 0000000..5a168fc
--- /dev/null
+++ b/pybuoy/api/forecasts.py
@@ -0,0 +1,133 @@
+from collections import defaultdict
+from datetime import datetime as dt
+from json import loads
+from typing import Optional
+
+import lxml.etree as ET  # type: ignore
+
+from pybuoy.api.base import ApiBase
+from pybuoy.const import API_PATH, Endpoints
+from pybuoy.observation import ForecastObservation, ForecastObservations
+
+
+class Forecasts(ApiBase):
+    # https://graphical.weather.gov/xml/rest.php
+    def get(self, lat: float, lon: float, start_date: str, end_date: str):
+        dwml_data = self.forecast_by_lat_lon(
+            lat=lat,
+            lon=lon,
+            start_date=dt.fromisoformat(start_date),
+            end_date=dt.fromisoformat(end_date),
+        )
+        grouped = self.__group_parameters_by_time(dwml_data["data"]["parameters"])
+
+        return ForecastObservations(
+            observations=[
+                ForecastObservation(grouped[key], dt.fromisoformat(key))
+                for key in grouped.keys()
+            ],
+            repr_limit=10,
+        )
+
+    def forecast_by_lat_lon(
+        self,
+        lat,
+        lon,
+        start_date: Optional[dt] = None,
+        end_date: Optional[dt] = None,
+        metric=False,
+    ):
+        return self._daily_forecast_from_location_info(
+            location_info=[lat, lon],
+            start_date=start_date,
+            end_date=end_date,
+            metric=metric,
+        )
+
+    def _daily_forecast_from_location_info(
+        self,
+        location_info,
+        start_date: Optional[dt] = None,
+        end_date: Optional[dt] = None,
+        metric=False,
+    ):
+        if not start_date:
+            start_date = dt.today()
+
+        lat, lon = location_info
+        params = {
+            "whichClient": "NDFDgen",
+            "lat": lat,
+            "lon": lon,
+            "product": "time-series",
+            "begin": start_date.isoformat(),
+            "end": None if end_date is None else end_date.isoformat(),
+            "Unit": "m" if metric else "e",
+            "wspd": "wspd",
+            "wdir": "wdir",
+            "waveh": "waveh",
+            "wgust": "wgust",
+            "Submit": "Submit",
+        }
+        response = self.make_request(API_PATH[Endpoints.FORECASTS.value], params=params)
+        xml_root = ET.fromstring(response)
+        parsed = self.xslt_transform(xml_root)
+
+        return loads(str(parsed))
+
+    def __group_parameters_by_time(self, parameters: dict):
+        """Group DWML as XSLT by observation recorded.
+
+        Args:
+            parameters (dict): XSLT object
+
+        Returns:
+            (
+                str,
+                defaultdict(
+                    ForecastKey,
+                    {
+                        "value": int,
+                        "label": str,
+                        "units": str,
+                    }
+                )
+            )
+
+        """
+        # TODO: refactor
+        # XSLT flatten data instead?
+        ele_map = {
+            "direction": ("wind-direction",),
+            "wind": (
+                "gust",
+                "sustained",
+            ),
+            "water-state": ("significant",),
+        }
+        default_val = {"value": "nan"}
+        groupedby_timestamp: dict[str, dict] = defaultdict(
+            lambda: defaultdict(
+                dict,
+                wave_height=default_val,
+                wind_direction=default_val,
+                wind_speed_gust=default_val,
+                wind_speed=default_val,
+            )
+        )
+        for k, v in parameters.items():
+            for key in v.keys():
+                if key in ele_map[k]:
+                    for period in parameters[k][key]["periods"]:
+                        timestamp = period["start-time"]
+                        new_key = "_".join(
+                            parameters[k][key]["name"].lower().split(" ")
+                        )
+                        groupedby_timestamp[timestamp][new_key] = {
+                            "value": period["value"],
+                            # TODO: transform to proper label here
+                            "label": parameters[k][key]["name"],
+                            "units": parameters[k][key]["units"],
+                        }
+
+        return groupedby_timestamp
diff --git a/pybuoy/buoy.py b/pybuoy/buoy.py
index 36f6d2d..2c38f67 100644
--- a/pybuoy/buoy.py
+++ b/pybuoy/buoy.py
@@ -1,3 +1,4 @@
+from pybuoy.api.forecasts import Forecasts
 from pybuoy.api.realtime import Realtime
 from pybuoy.api.stations import Stations
 
@@ -21,9 +22,14 @@ class Buoy:
     """
 
     def __init__(self):
+        self._forecasts = Forecasts()
         self._realtime = Realtime()
         self._stations = Stations()
 
+    @property
+    def forecasts(self) -> Forecasts:
+        return self._forecasts
+
     @property
     def realtime(self) -> Realtime:
         return self._realtime
diff --git a/pybuoy/const.py b/pybuoy/const.py
index dfd7e40..14b3e26 100644
--- a/pybuoy/const.py
+++ b/pybuoy/const.py
@@ -7,6 +7,7 @@
 
 class Endpoints(Enum):
     ACTIVE_STATIONS = "active_stations"
+    FORECASTS = "forecasts"
     REALTIME = "realtime2"
 
 
diff --git a/pybuoy/endpoints.py b/pybuoy/endpoints.py
index b5d3d4f..2c3b7ee 100644
--- a/pybuoy/endpoints.py
+++ b/pybuoy/endpoints.py
@@ -1,8 +1,10 @@
 """List of API endpoints pybuoy knows about."""
 
 _BASE_URL = "https://www.ndbc.noaa.gov"
+_FORECASTS_URL = "https://graphical.weather.gov/xml/sample_products/browser_interface/ndfdXMLclient.php"  # noqa: E501
 
 API_PATH: dict[str, str] = {
     "active_stations": f"{_BASE_URL}/activestations",
+    "forecasts": f"{_FORECASTS_URL}",
     "realtime2": f"{_BASE_URL}/data/realtime2",
 }
diff --git a/pybuoy/exceptions.py b/pybuoy/exceptions.py
index cbfaa67..4947213 100644
--- a/pybuoy/exceptions.py
+++ b/pybuoy/exceptions.py
@@ -1,5 +1,29 @@
 """pybuoy exception classes."""
+from requests import HTTPError  # type: ignore
 
 
-class BuoyException(Exception):
+class BaseException(Exception):
     """Base Exception that all other exception classes extend."""
+
+    ...
+
+
+class HTTPException(HTTPError):
+    ...
+
+
+class NDBCException(BaseException):
+    ...
+
+
+class NOAAException(BaseException):
+    ...
+
+
+class DWMLException(NOAAException):
+    """NOAA DWML Exception.
+
+    ref: https://graphical.weather.gov/xml/DWMLgen/schema/DWML.xsd
+    """
+
+    ...
diff --git a/pybuoy/mixins/noaa.xsl b/pybuoy/mixins/noaa.xsl
new file mode 100644
index 0000000..fa51fe5
--- /dev/null
+++ b/pybuoy/mixins/noaa.xsl
@@ -0,0 +1,449 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<xsl:stylesheet version="1.0"
+    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+
+    <xsl:output method="text" omit-xml-declaration="yes" indent="no" />
+
+    <!-- Function to generate the time periods -->
+    <xsl:template name="generate-periods">
+        <xsl:param name="layout-key" />
+        <xsl:param name="duration" />
+        <xsl:param name="position" />
+        <xsl:param name="quote-values" select="false" />
+        {
+            "start-time": "<xsl:value-of select="//layout-key[text()=$layout-key]/../start-valid-time[position()=$position]" />",
+            "duration-hours": <xsl:value-of select="$duration" />,
+            "value":
+                <xsl:if test="current()/@xsi:nil='true'">null</xsl:if>
+                <xsl:if test="not(current()/@xsi:nil='true')">
+                    <xsl:if test="$quote-values=1">"</xsl:if><xsl:value-of select="current()" /><xsl:if test="$quote-values=1">"</xsl:if>
+                </xsl:if>
+        }
+    </xsl:template>
+
+    <!-- Function to get number of hours from a time layout key -->
+    <xsl:template name="find-period-duration">
+        <xsl:param name="layout-key" />
+        <xsl:if test="contains($layout-key, 'h-n')">
+            <xsl:variable name="tmp" select="substring-after($layout-key, 'k-p')" />
+            <xsl:variable name="hours" select="substring-before($tmp, 'h-n')" />
+            <xsl:value-of select="$hours" />
+        </xsl:if>
+        <xsl:if test="contains($layout-key, 'd-n')">
+            <xsl:variable name="tmp" select="substring-after($layout-key, 'k-p')" />
+            <xsl:variable name="days" select="substring-before($tmp, 'd-n')" />
+            <xsl:value-of select="$days*24" />
+        </xsl:if>
+    </xsl:template>
+
+    <!-- Function to generate the JSON URL for current conditions -->
+    <xsl:template name="generate-conditions-url">
+        <xsl:param name="lat" />
+        <xsl:param name="lng" />
+        <xsl:variable name="and"><![CDATA[&]]></xsl:variable>
+        <xsl:variable name="url" select="'http://forecast.weather.gov/MapClick.php?FcstType=json'" />
+        <xsl:value-of select="concat($url,$and,'lat=',$lat,$and,'lon=',$lng)" disable-output-escaping="yes" />
+    </xsl:template>
+
+    <xsl:template match="/dwml">
+        <xsl:if test="@version != '1.0'">
+            { "error": "Unrecognized DWML version" }
+        </xsl:if>
+        <xsl:if test="@version = '1.0'">
+            {
+                "head": {
+                    <xsl:apply-templates select="head" />
+                },
+                "data": {
+                    <xsl:apply-templates select="data" />
+                }
+            }
+        </xsl:if>
+    </xsl:template>
+
+    <!-- dwml/head -->
+    <xsl:template match="head">
+        "title": "<xsl:value-of select="product/title" />",
+        "creation-date": "<xsl:value-of select="product/creation-date" />",
+        "credit": "<xsl:value-of select="source/credit" />",
+        "credit-logo": "<xsl:value-of select="source/credit-logo" />",
+        "title": "<xsl:value-of select="product/title" />",
+        "disclaimer": "<xsl:value-of select="source/disclaimer" />",
+        "feedback": "<xsl:value-of select="source/feedback" />"
+    </xsl:template>
+
+    <!-- dwml/data -->
+    <xsl:template match="data">
+        <xsl:variable name="lat" select="location/point/@latitude" />
+        <xsl:variable name="lng" select="location/point/@longitude" />
+        <xsl:variable name="conditions-url">
+            <xsl:call-template name="generate-conditions-url">
+                <xsl:with-param name="lat" select="$lat" />
+                <xsl:with-param name="lng" select="$lng" />
+            </xsl:call-template>
+        </xsl:variable>
+        "location": {
+            "latitude": <xsl:value-of select="$lat" />,
+            "longitude": <xsl:value-of select="$lng" />
+        },
+        "more-information": "<xsl:value-of select="moreWeatherInformation" />",
+        "conditions-url": "<xsl:value-of select="$conditions-url" />",
+        "parameters": {
+            <xsl:if test="parameters/water-state">
+                "water-state": {
+                    <xsl:for-each select="parameters/water-state">
+                        <xsl:call-template name="waves" /><xsl:if test="position() != last()">, </xsl:if>
+                    </xsl:for-each>
+                }
+            </xsl:if>
+            <xsl:if test="parameters/temperature">
+                , "temperature": {
+                    <xsl:for-each select="parameters/temperature">
+                        <xsl:call-template name="temperature" /><xsl:if test="position() != last()">, </xsl:if>
+                    </xsl:for-each>
+                }
+            </xsl:if>
+            <xsl:if test="parameters/probability-of-precipitation">
+                , "probability-of-precipitation": {
+                    <xsl:apply-templates select="parameters/probability-of-precipitation" />
+                }
+            </xsl:if>
+            <xsl:if test="parameters/precipitation">
+                , "precipitation": {
+                    <xsl:for-each select="parameters/precipitation">
+                        <xsl:call-template name="precipitation" /><xsl:if test="position() != last()">, </xsl:if>
+                    </xsl:for-each>
+                }
+            </xsl:if>
+            <xsl:if test="parameters/wind-speed">
+                , "wind": {
+                    <xsl:for-each select="parameters/wind-speed">
+                        <xsl:call-template name="wind-speed" /><xsl:if test="position() != last()">, </xsl:if>
+                    </xsl:for-each>
+                }
+            </xsl:if>
+            <xsl:if test="parameters/direction">
+                , "direction": {
+                    <xsl:for-each select="parameters/direction">
+                        <xsl:call-template name="direction" /><xsl:if test="position() != last()">, </xsl:if>
+                    </xsl:for-each>
+                }
+            </xsl:if>
+            <xsl:if test="parameters/cloud-amount">
+                , "cloud-amount": {
+                    <xsl:apply-templates select="parameters/cloud-amount" />
+                }
+            </xsl:if>
+            <xsl:if test="parameters/humidity">
+                , "humidity": {
+                    <xsl:apply-templates select="parameters/humidity" />
+                }
+            </xsl:if>
+            <xsl:if test="parameters/weather">
+                , "conditions": {
+                    <xsl:apply-templates select="parameters/weather" />
+                }
+            </xsl:if>
+            <xsl:if test="parameters/conditions-icon">
+                , "condition-icons": {
+                    <xsl:apply-templates select="parameters/conditions-icon" />
+                }
+            </xsl:if>
+            <xsl:if test="parameters/hazards">
+                , "hazards": {
+                    <xsl:apply-templates select="parameters/hazards" />
+                }
+            </xsl:if>
+        }
+    </xsl:template>
+
+    <!-- conditions icons -->
+    <xsl:template match="conditions-icon">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "name": "<xsl:value-of select="name" />",
+        "periods": [
+            <xsl:for-each select="icon-link">
+                <xsl:call-template name="generate-periods">
+                    <xsl:with-param name="layout-key" select="$tl-key" />
+                    <xsl:with-param name="position" select="position()" />
+                    <xsl:with-param name="duration" select="$duration" />
+                    <xsl:with-param name="quote-values" select="1" />
+                </xsl:call-template><xsl:if test="position() != last()">, </xsl:if>
+            </xsl:for-each>
+        ]
+    </xsl:template>
+
+    <!-- humidity -->
+    <xsl:template match="humidity">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "name": "<xsl:value-of select="name" />",
+        "units": "<xsl:value-of select="@units" />",
+        "periods": [
+            <xsl:for-each select="value">
+                <xsl:call-template name="generate-periods">
+                    <xsl:with-param name="layout-key" select="$tl-key" />
+                    <xsl:with-param name="position" select="position()" />
+                    <xsl:with-param name="duration" select="$duration" />
+                </xsl:call-template><xsl:if test="position() != last()">, </xsl:if>
+            </xsl:for-each>
+        ]
+    </xsl:template>
+
+    <!-- cloud-amount -->
+    <xsl:template match="cloud-amount">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "name": "<xsl:value-of select="name" />",
+        "units": "<xsl:value-of select="@units" />",
+        "periods": [
+            <xsl:for-each select="value">
+                <xsl:call-template name="generate-periods">
+                    <xsl:with-param name="layout-key" select="$tl-key" />
+                    <xsl:with-param name="position" select="position()" />
+                    <xsl:with-param name="duration" select="$duration" />
+                </xsl:call-template><xsl:if test="position() != last()">, </xsl:if>
+            </xsl:for-each>
+        ]
+    </xsl:template>
+
+    <!-- wind-speed (multiple types) -->
+    <xsl:template name="wind-speed">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "<xsl:value-of select="@type" />": {
+            "name": "<xsl:value-of select="name" />",
+            "units": "<xsl:value-of select="@units" />",
+            "periods": [
+                <xsl:for-each select="value">
+                    <xsl:call-template name="generate-periods">
+                        <xsl:with-param name="layout-key" select="$tl-key" />
+                        <xsl:with-param name="position" select="position()" />
+                        <xsl:with-param name="duration" select="$duration" />
+                    </xsl:call-template><xsl:if test="position() != last()">, </xsl:if>
+                </xsl:for-each>
+            ]
+        }
+    </xsl:template>
+
+     <!-- wind direction -->
+    <xsl:template name="direction">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "wind-direction": {
+            "name": "<xsl:value-of select="name" />",
+            "units": "<xsl:value-of select="@units" />",
+            "periods": [
+                <xsl:for-each select="value">
+                    <xsl:call-template name="generate-periods">
+                        <xsl:with-param name="layout-key" select="$tl-key" />
+                        <xsl:with-param name="position" select="position()" />
+                        <xsl:with-param name="duration" select="$duration" />
+                    </xsl:call-template>
+                    <xsl:if test="position() != last()">, </xsl:if>
+                </xsl:for-each>
+            ]
+        }
+    </xsl:template>
+
+    <!-- temperatures (multiple types) -->
+    <xsl:template name="temperature">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "<xsl:value-of select="@type" />": {
+            "name": "<xsl:value-of select="name" />",
+            "units": "<xsl:value-of select="@units" />",
+            "periods": [
+                <xsl:for-each select="value">
+                    <xsl:call-template name="generate-periods">
+                        <xsl:with-param name="layout-key" select="$tl-key" />
+                        <xsl:with-param name="position" select="position()" />
+                        <xsl:with-param name="duration" select="$duration" />
+                    </xsl:call-template>
+                    <xsl:if test="position() != last()">, </xsl:if>
+                </xsl:for-each>
+            ]
+        }
+    </xsl:template>
+
+    <!-- probability-of-precipitation -->
+    <xsl:template match="probability-of-precipitation">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "name": "<xsl:value-of select="name" />",
+        "units": "<xsl:value-of select="@units" />",
+        "periods": [
+        <xsl:for-each select="value">
+            <xsl:call-template name="generate-periods">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+                <xsl:with-param name="position" select="position()" />
+                <xsl:with-param name="duration" select="$duration" />
+            </xsl:call-template>
+            <xsl:if test="position() != last()">, </xsl:if>
+        </xsl:for-each>
+        ]
+    </xsl:template>
+
+    <!-- precipitation (multiple types) -->
+    <xsl:template name="precipitation">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "<xsl:value-of select="@type" />": {
+            "name": "<xsl:value-of select="name" />",
+            "units": "<xsl:value-of select="@units" />",
+            "periods": [
+            <xsl:for-each select="value">
+                <xsl:call-template name="generate-periods">
+                    <xsl:with-param name="layout-key" select="$tl-key" />
+                    <xsl:with-param name="position" select="position()" />
+                    <xsl:with-param name="duration" select="$duration" />
+                </xsl:call-template>
+                <xsl:if test="position() != last()">, </xsl:if>
+            </xsl:for-each>
+            ]
+        }
+    </xsl:template>
+
+    <!-- conditions -->
+    <xsl:template match="weather">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "name": "<xsl:value-of select="name" />",
+        "periods": [
+            <xsl:for-each select="weather-conditions">
+                <xsl:variable name="pos" select="position()" />
+                {
+                    "start-time": "<xsl:value-of select="//layout-key[text()=$tl-key]/../start-valid-time[position()=$pos]" />",
+                    "duration-hours": <xsl:value-of select="$duration" />,
+                    "summary":
+                        <xsl:if test="not(current()/@weather-summary)">null,</xsl:if>
+                        <xsl:if test="current()/@weather-summary">
+                            "<xsl:value-of select="current()/@weather-summary" />",
+                        </xsl:if>
+                    "condition":
+                        <xsl:if test="current()/@xsi:nil='true'">null</xsl:if>
+                        <xsl:if test="not(current()/@xsi:nil='true')">
+                            <xsl:if test="current()[not(child::node())]">null</xsl:if>
+                        </xsl:if>
+                        <xsl:if test="current()[child::node()]">
+                            <xsl:if test="not(current()/@xsi:nil='true')">
+                                {
+                                    "coverage": "<xsl:value-of select="value/@coverage" />",
+                                    "intensity": "<xsl:value-of select="value/@intensity" />",
+                                    "type": "<xsl:value-of select="value/@weather-type" />",
+                                    "qualifier": "<xsl:value-of select="value/@qualifier" />",
+                                    "visibility":
+                                        <xsl:if test="value/visibility/@xsi:nil='true'">null</xsl:if>
+                                        <xsl:if test="not(value/visibility/@xsi:nil='true')">
+                                            <xsl:if test="not(value/visibility)">null</xsl:if>
+                                            <xsl:if test="value/visibility">
+                                                "<xsl:value-of select="value/visibility" />"
+                                            </xsl:if>
+                                        </xsl:if>
+                                }
+                            </xsl:if>
+                        </xsl:if>
+                }<xsl:if test="position() != last()">, </xsl:if>
+            </xsl:for-each>
+        ]
+    </xsl:template>
+
+    <!-- hazards -->
+    <xsl:template match="hazards">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "name": "<xsl:value-of select="name" />",
+        "periods": [
+            <xsl:for-each select="hazard-conditions">
+                <xsl:variable name="pos" select="position()" />
+                {
+                    "start-time": "<xsl:value-of select="//layout-key[text()=$tl-key]/../start-valid-time[position()=$pos]" />",
+                    "duration-hours": <xsl:value-of select="$duration" />,
+                    "hazard":
+                        <xsl:if test="current()/@xsi:nil='true'">null</xsl:if>
+                        <xsl:if test="not(current()/@xsi:nil='true')">
+                            <xsl:if test="current()[not(child::node())]">null</xsl:if>
+                            <xsl:if test="current()[child::node()]">
+                                <xsl:if test="not(current()/@xsi:nil='true')">
+                                    {
+                                        "hazard-code": "<xsl:value-of select="hazard/@hazardCode" />",
+                                        "phenomena": "<xsl:value-of select="hazard/@phenomena" />",
+                                        "significance": "<xsl:value-of select="hazard/@significance" />",
+                                        "hazard-type": "<xsl:value-of select="hazard/@hazardType" />",
+                                        "hazard-url": "<xsl:value-of select="hazard/hazardTextURL" />"
+                                    }
+                                </xsl:if>
+                            </xsl:if>
+                        </xsl:if>
+                }<xsl:if test="position() != last()">, </xsl:if>
+            </xsl:for-each>
+        ]
+    </xsl:template>
+
+    <!-- water-state (wave type) -->
+    <xsl:template name="waves">
+        <xsl:variable name="tl-key" select="@time-layout" />
+        <xsl:variable name="duration">
+            <xsl:call-template name="find-period-duration">
+                <xsl:with-param name="layout-key" select="$tl-key" />
+            </xsl:call-template>
+        </xsl:variable>
+        "<xsl:value-of select="waves/@type" />": {
+            "name": "<xsl:value-of select="waves/name" />",
+            "units": "<xsl:value-of select="waves/@units" />",
+            "periods": [
+                <xsl:for-each select="waves/value">
+                    <xsl:call-template name="generate-periods">
+                        <xsl:with-param name="layout-key" select="$tl-key" />
+                        <xsl:with-param name="position" select="position()" />
+                        <xsl:with-param name="duration" select="$duration" />
+                    </xsl:call-template>
+                    <xsl:if test="position() != last()">, </xsl:if>
+                </xsl:for-each>
+            ]
+        }
+    </xsl:template>
+
+</xsl:stylesheet>
diff --git a/pybuoy/mixins/parser.py b/pybuoy/mixins/parser.py
index 3ebb6b8..97bc98a 100644
--- a/pybuoy/mixins/parser.py
+++ b/pybuoy/mixins/parser.py
@@ -1,11 +1,14 @@
 """Provide the ParserMixin class."""
 from collections import defaultdict
 from datetime import datetime as dt
+from os.path import dirname, join
 from typing import Any
 from xml.etree.ElementTree import Element, fromstring
 
+import lxml.etree as ET  # type: ignore
+
 from pybuoy.const import RealtimeDatasets, RealtimeDatasetsValues
-from pybuoy.exceptions import BuoyException
+from pybuoy.exceptions import NDBCException
 from pybuoy.observation import MeteorologicalObservation, WaveSummaryObservation
 from pybuoy.observation.observations import (
     MeteorologicalObservations,
@@ -17,9 +20,12 @@
 class ParserMixin:
     """Parser mixin supports handling of third-party data."""
 
+    def __init__(self) -> None:
+        self.__set_xslt_transform()
+
     def etree_to_dict(self, element: Element):
         """Parse XML Element to dictionary."""
-        from_etree: dict[str, dict[str, Any] | Any] = {  # TODO: improve typing
+        from_etree: dict[str, dict[str, Any] | Any] = {
             element.tag: {} if element.attrib else None
         }
         children = list(element)
@@ -30,11 +36,14 @@ def etree_to_dict(self, element: Element):
                     parsed_children[key].append(value)
             from_etree = {
                 element.tag: {
-                    k: v[0] if len(v) == 1 else v for k, v in parsed_children.items()
+                    key: value[0] if len(value) == 1 else value
+                    for key, value in parsed_children.items()
                 }
             }
         if element.attrib:
-            from_etree[element.tag].update((k, v) for k, v in element.attrib.items())
+            from_etree[element.tag].update(
+                (key, value) for key, value in element.attrib.items()
+            )
         if element.text:
             text = element.text.strip()
             if children or element.attrib:
@@ -52,7 +61,7 @@ def parse(
         obs = self.__clean_realtime_data(data=data, dataset=dataset)
         # TODO: refine error handling
         if obs is None or len(obs) == 0:
-            raise BuoyException
+            raise NDBCException
 
         match dataset:
             case RealtimeDatasets.spec.value:
@@ -65,18 +74,23 @@ def parse(
             case _:
                 return data
 
+    @property
+    def xslt_transform(self) -> ET.XSLT:
+        return self._xslt_transform
+
     # TODO: consider station class/dto like observations
     def _clean_activestation_data(self, data: str):
         xml_tree_root = fromstring(data)
         # TODO: consider incorporating `etree_to_dict`
         return [dict(el.items()) for el in xml_tree_root.findall("station")]
 
+    # TODO: fix missing latest row
     def __clean_realtime_data(
         self, data: str, dataset: RealtimeDatasetsValues
     ) -> list[MeteorologicalObservation] | list[WaveSummaryObservation]:
         if data is None:
             # TODO: handle when request not successful
-            raise BuoyException
+            raise NDBCException
 
         # TODO: consider csv module
         rows = data.strip().split("\n")
@@ -94,6 +108,7 @@ def __clean_realtime_data(
                 int(record_array[3]),
                 int(record_array[4]),
             )
+            # TODO: investigate adding Strategy Pattern here
             if dataset == RealtimeDatasets.txt.value:
                 observation = self.__parse_meteorological_record(
                     date_recorded=date_recorded,
@@ -110,16 +125,19 @@ def __clean_realtime_data(
 
         return obs
 
-    # TODO: refactor
     def __parse_meteorological_record(self, date_recorded: dt, headers, records):
         values: dict[MeteorologicalKey, str] = {
             MeteorologicalKey[headers[idx]]: value for idx, value in enumerate(records)
         }
         return MeteorologicalObservation(values, date_recorded)
 
-    # TODO: combine with above
+    # TODO: refactor (e.g., combine with __parse_meteorological_record)
     def __parse_wave_summary_record(self, date_recorded: dt, headers, records):
         values: dict[WaveSummaryKey, str] = {
             WaveSummaryKey[headers[idx]]: value for idx, value in enumerate(records)
         }
         return WaveSummaryObservation(values, date_recorded)
+
+    def __set_xslt_transform(self, xsl_filename="noaa.xsl"):
+        forecast_xslt = ET.parse(join(dirname(__file__), xsl_filename))
+        self._xslt_transform = ET.XSLT(forecast_xslt)
diff --git a/pybuoy/observation/__init__.py b/pybuoy/observation/__init__.py
index 0f57b03..0fab37e 100644
--- a/pybuoy/observation/__init__.py
+++ b/pybuoy/observation/__init__.py
@@ -1,10 +1,14 @@
 from pybuoy.observation.observation import MeteorologicalObservation  # noqa: F401
-from pybuoy.observation.observation import WaveSummaryObservation  # noqa: F401
+from pybuoy.observation.observation import (  # noqa: F401
+    ForecastObservation,
+    WaveSummaryObservation,
+)
 from pybuoy.observation.observation_datum import (  # noqa: F401
     ObservationFloatDatum,
     ObservationStringDatum,
 )
 from pybuoy.observation.observations import (  # noqa: F401
+    ForecastObservations,
     MeteorologicalObservations,
     WaveSummaryObservations,
 )
diff --git a/pybuoy/observation/observation.py b/pybuoy/observation/observation.py
index 67858aa..766bbc4 100644
--- a/pybuoy/observation/observation.py
+++ b/pybuoy/observation/observation.py
@@ -6,6 +6,7 @@
     ObservationStringDatum,
 )
 from pybuoy.unit_mappings import (
+    FORECAST,
     METEOROLOGICAL,
     WAVE_SUMMARY,
     MeteorologicalKey,
@@ -217,3 +218,48 @@ def average_wave_period(self) -> ObservationFloatDatum:
     def dominant_wave_direction(self) -> ObservationFloatDatum:
         """Return observed direction of waves at dominant period."""
         return self._dominant_wave_direction  # type: ignore # this is dynamically set
+
+
+class ForecastObservation(BaseObservation):
+    """Encapsulates forecasted `Buoy` data."""
+
+    def __init__(
+        self,
+        # TODO: fix dict key type
+        values: dict[str, dict],
+        datetime: Optional[datetime] = None,
+    ):
+        super().__init__(datetime=datetime)
+        for key in FORECAST:
+            new_key = "_".join(FORECAST[key]["label"].lower().split(" "))
+            default_val = {"value": "nan"}  # TODO: refactor
+            value = values.get(key.name, default_val)
+            value_value = value.get("value", "nan")  # TODO: rename
+            setattr(
+                self,
+                f"_{new_key}",
+                ObservationFloatDatum(value_value, FORECAST[key]),
+            )
+
+    def __str__(self):
+        return f"Prediction({self.datetime})"
+
+    @property
+    def wind_direction(self) -> ObservationFloatDatum:
+        """Return observed wind direction."""
+        return self._wind_direction  # type: ignore # this is dynamically set
+
+    @property
+    def wind_speed(self) -> ObservationFloatDatum:
+        """Return observed wind speed."""
+        return self._wind_speed  # type: ignore # this is dynamically set
+
+    @property
+    def wind_gust(self) -> ObservationFloatDatum:
+        """Return observed wind gust."""
+        return self._wind_gust  # type: ignore # this is dynamically set
+
+    @property
+    def wave_height(self) -> ObservationFloatDatum:
+        """Return observed wave height."""
+        return self._wave_height  # type: ignore # this is dynamically set
diff --git a/pybuoy/observation/observations.py b/pybuoy/observation/observations.py
index ddce6d6..280ccc2 100644
--- a/pybuoy/observation/observations.py
+++ b/pybuoy/observation/observations.py
@@ -1,6 +1,10 @@
 from typing import Generic, Iterator, Optional, TypeVar
 
-from pybuoy.observation import MeteorologicalObservation, WaveSummaryObservation
+from pybuoy.observation import (
+    ForecastObservation,
+    MeteorologicalObservation,
+    WaveSummaryObservation,
+)
 from pybuoy.observation.observation import BaseObservation
 
 ObservationType = TypeVar("ObservationType", bound=BaseObservation)
@@ -10,11 +14,11 @@ class BaseObservations(Generic[ObservationType]):
     """Observations class encapsulates `Observation`s by datetime."""
 
     def __init__(
-        self,
-        observations: Optional[list[ObservationType]] = None,
+        self, observations: Optional[list[ObservationType]] = None, repr_limit=1000
     ):
         # TODO: improve error handling
         self._data = observations if observations is not None else []
+        self._repr_limit = repr_limit
         self._size = len(self._data)
 
     @property
@@ -33,7 +37,7 @@ def __iter__(self) -> Iterator[ObservationType]:
         return iter(self._data)
 
     def __repr__(self) -> str:
-        if len(self._data) < 1000:
+        if len(self._data) < self._repr_limit:
             return "{}({})".format(
                 self.__class__.__name__,
                 ", ".join("{}={!r}".format(k, v) for k, v in self.__dict__.items()),
@@ -59,3 +63,7 @@ class MeteorologicalObservations(BaseObservations[MeteorologicalObservation]):
 
 class WaveSummaryObservations(BaseObservations[WaveSummaryObservation]):
     ...
+
+
+class ForecastObservations(BaseObservations[ForecastObservation]):
+    ...
diff --git a/pybuoy/unit_mappings.py b/pybuoy/unit_mappings.py
index 6e30611..7a2a37c 100644
--- a/pybuoy/unit_mappings.py
+++ b/pybuoy/unit_mappings.py
@@ -11,6 +11,13 @@ class BaseKey(Enum):
     ...
 
 
+class ForecastKey(BaseKey):
+    wind_direction = "Wind Direction"
+    wave_height = "Wave Height"
+    wind_speed = "Wind Speed"
+    wind_speed_gust = "Wind Speed Gust"
+
+
 class MeteorologicalKey(BaseKey):
     WDIR = "WDIR"
     WSPD = "WSPD"
@@ -106,66 +113,9 @@ class MeasurementsAndUnits(TypedDict):
     },  # direction of waves at dominant period (DPD)
 }
 
-
-# TODO: update below for dynamic mapping
-
-WAVE_UNITS = {
-    "sea_surface_wave_significant_height": "meters",
-    "sea_surface_wave_peak_period": "seconds",
-    "sea_surface_wave_mean_period": "seconds",
-    "sea_surface_swell_wave_significant_height": "meters",
-    "sea_surface_swell_wave_period": "seconds",
-    "sea_surface_wind_wave_significant_height": "meters",
-    "sea_surface_wind_wave_period": "seconds",
-    "sea_water_temperature": "celcius",
-    "sea_surface_wave_to_direction": "degrees",
-    "sea_surface_swell_wave_to_direction": "degrees",
-    "sea_surface_wind_wave_to_direction": "degrees",
-    "number_of_frequencies": "count",
-    "center_frequencies": "Hz",
-    "bandwidths": "Hz",
-    "spectral_energy": "m**2/Hz",
-    "mean_wave_direction": "degrees",
-    "principal_wave_direction": "degrees",
-    "polar_coordinate_r1": "1",
-    "polar_coordinate_r2": "1",
-    "calculation_method": None,
-    "sampling_rate": "Hz",
-}
-
-CURRENT_UNITS = {
-    "bin": "count",
-    "depth": "meters",
-    "direction_of_sea_water_velocity": "degrees",
-    "sea_water_speed": "c/s",
-    "upward_sea_water_velocity": "c/s",
-    "error_velocity": "c/s",
-    "platform_orientation": "degrees",
-    "platform_pitch_angle": "degrees",
-    "platform_roll_angle": "degrees",
-    "sea_water_temperature": "celcius",
-    "pct_good_3_beam": "percent",
-    "pct_good_4_beam": "percent",
-    "pct_rejected": "percent",
-    "pct_bad": "percent",
-    "echo_intensity_beam1": "count",
-    "echo_intensity_beam2": "count",
-    "echo_intensity_beam3": "count",
-    "echo_intensity_beam4": "count",
-    "correlation_magnitude_beam1": "count",
-    "correlation_magnitude_beam2": "count",
-    "correlation_magnitude_beam3": "count",
-    "correlation_magnitude_beam4": "count",
-    "quality_flags": None,
+FORECAST: dict[ForecastKey, MeasurementsAndUnits] = {
+    ForecastKey.wave_height: {"label": "Wave Height", "unit": "feet"},
+    ForecastKey.wind_direction: {"label": "Wind Direction", "unit": "degrees"},
+    ForecastKey.wind_speed: {"label": "Wind Speed", "unit": "knots"},
+    ForecastKey.wind_speed_gust: {"label": "Wind Gust", "unit": "knots"},
 }
-
-WIND_UNITS = {
-    "wind_from_direction": "degrees",
-    "wind_speed": "m/s",
-    "wind_speed_of_gust": "m/s",
-    "upward_air_velocity": "m/s",
-}
-
-# REALTIME_WAVE_UNITS = {}
-
-# SPECTRAL_UNITS = {}
diff --git a/pyproject.toml b/pyproject.toml
index 41765f8..3274db2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,9 +4,9 @@ version = "0.4.3"
 authors = ["Kyle J. Burda <kylejbdev@gmail.com>"]
 classifiers=[
   "Operating System :: OS Independent",
-  "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
 ]
-description = "Python wrapper for NDBC data."
+description = "Python wrapper for NOAA and NDBC REST web services."
 documentation = "https://pybuoy.readthedocs.io"
 include = ["LICENSE"]
 keywords=["NDBC", "NOAA", "api", "buoy", "weather", "wrapper"]
@@ -18,14 +18,15 @@ readme = "README.rst"
 "Bug Tracker" = "https://github.com/clairBuoyant/pybuoy/issues"
 
 [tool.poetry.dependencies]
-python = "^3.10"
+python = "^3.11"
 requests = "^2.28.2"
+lxml = "^4.9.2"
 
 [tool.poetry.group.dev.dependencies]
-black = "^22.12.0"
+black = "^23.1.0"
 flake8 = "^6.0.0"
-isort = { version = "^5.12.0", extras = ["pyproject"]}
-mypy = "^1.00"
+isort = { version = "^5.11.5", extras = ["pyproject"]}
+mypy = "^1.0.1"
 pytest = "^7.2.1"
 pre-commit = "^3.0.4"
 types-requests = "^2.28.11"


Test patch:
diff --git a/tests/test_forecast.py b/tests/test_forecast.py
new file mode 100644
index 0000000..5a82277
--- /dev/null
+++ b/tests/test_forecast.py
@@ -0,0 +1,22 @@
+from datetime import datetime, timedelta
+from random import randrange
+
+from pybuoy import Buoy
+from pybuoy.observation.observation import ForecastObservation
+
+
+def test_forecast_data(test_pybuoy: Buoy):
+    test_begin_date = (datetime.now() + timedelta(1)).isoformat()
+    test_end_date = (datetime.now() + timedelta(5)).isoformat()
+
+    response = test_pybuoy.forecasts.get(
+        40.369, -73.702531, test_begin_date, test_end_date
+    )
+    for record in response:
+        assert isinstance(record, ForecastObservation)
+
+    random_record = response[randrange(len(response.reports))]
+    assert hasattr(random_record, "wind_direction")
+    assert hasattr(random_record, "wind_speed")
+    assert hasattr(random_record, "wind_gust")
+    assert hasattr(random_record, "wave_height")