Initial Climate Mood Analysis project

This commit is contained in:
Justin Fisher
2026-06-11 14:50:33 -04:00
commit 18e099ce84
34 changed files with 18807 additions and 0 deletions
@@ -0,0 +1,163 @@
from __future__ import annotations
import sys
import unittest
import urllib.error
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from download_nsrdb_county_polygon_archives import ( # noqa: E402
ArchiveDownloadEvent,
ArchiveDownloadState,
ArchiveDownloadStateMachine,
InvalidArchiveDownloadTransition,
PolygonArchiveDownloadError,
download_file,
output_name_for_response,
)
class FakeResponse:
def __init__(self, content: bytes) -> None:
self.content = content
self.offset = 0
def __enter__(self) -> FakeResponse:
return self
def __exit__(self, *args: object) -> None:
return None
def read(self, size: int) -> bytes:
chunk = self.content[self.offset : self.offset + size]
self.offset += len(chunk)
return chunk
class ArchiveDownloadStateMachineTests(unittest.TestCase):
def test_legacy_ghi_response_name_keeps_existing_archive_name(self) -> None:
response_path = Path("01001_goes-tmy_tmy-2024_ghi_response.json")
self.assertEqual(
output_name_for_response(response_path),
"01001_goes-tmy_tmy-2024_ghi.zip",
)
def test_successful_download_lifecycle(self) -> None:
machine = ArchiveDownloadStateMachine("01001_response.json")
machine.transition(ArchiveDownloadEvent.START)
with TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "01001.zip"
with patch(
"download_nsrdb_county_polygon_archives.urllib.request.urlopen",
return_value=FakeResponse(b"archive"),
):
state = download_file(
"https://example.com/01001.zip",
output_path,
timeout=30,
overwrite=False,
machine=machine,
)
self.assertEqual(output_path.read_bytes(), b"archive")
self.assertEqual(state, ArchiveDownloadState.DOWNLOADED)
self.assertEqual(
machine.history,
[
ArchiveDownloadState.QUEUED,
ArchiveDownloadState.CHECKING,
ArchiveDownloadState.DOWNLOADING,
ArchiveDownloadState.DOWNLOADED,
],
)
def test_existing_archive_is_skipped(self) -> None:
machine = ArchiveDownloadStateMachine("01001_response.json")
machine.transition(ArchiveDownloadEvent.START)
with TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "01001.zip"
output_path.write_bytes(b"existing")
state = download_file(
"https://example.com/01001.zip",
output_path,
timeout=30,
overwrite=False,
machine=machine,
)
self.assertEqual(state, ArchiveDownloadState.SKIPPED)
def test_pending_s3_archive_enters_pending_state(self) -> None:
machine = ArchiveDownloadStateMachine("01001_response.json")
machine.transition(ArchiveDownloadEvent.START)
error = urllib.error.HTTPError(
"https://bucket.s3.amazonaws.com/01001.zip",
403,
"Forbidden",
{},
None,
)
with TemporaryDirectory() as temp_dir:
with patch(
"download_nsrdb_county_polygon_archives.urllib.request.urlopen",
side_effect=error,
):
state = download_file(
error.url,
Path(temp_dir) / "01001.zip",
timeout=30,
overwrite=False,
machine=machine,
)
self.assertEqual(state, ArchiveDownloadState.PENDING)
def test_http_error_enters_failed_state(self) -> None:
machine = ArchiveDownloadStateMachine("01001_response.json")
machine.transition(ArchiveDownloadEvent.START)
error = urllib.error.HTTPError(
"https://example.com/01001.zip",
500,
"Server Error",
{},
None,
)
with TemporaryDirectory() as temp_dir:
with patch(
"download_nsrdb_county_polygon_archives.urllib.request.urlopen",
side_effect=error,
):
with self.assertRaises(PolygonArchiveDownloadError):
download_file(
error.url,
Path(temp_dir) / "01001.zip",
timeout=30,
overwrite=False,
machine=machine,
)
self.assertEqual(machine.state, ArchiveDownloadState.FAILED)
def test_invalid_transition_is_rejected(self) -> None:
machine = ArchiveDownloadStateMachine("01001_response.json")
with self.assertRaisesRegex(
InvalidArchiveDownloadTransition,
"download_succeeded while queued",
):
machine.transition(ArchiveDownloadEvent.DOWNLOAD_SUCCEEDED)
if __name__ == "__main__":
unittest.main()
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import download_nsrdb_county_polygon_cloud_archives as cloud_download # noqa: E402
import download_nsrdb_county_polygon_ghi_archives as ghi_download # noqa: E402
import request_nsrdb_county_polygon_cloud_archives as cloud_request # noqa: E402
import request_nsrdb_county_polygon_ghi_archives as ghi_request # noqa: E402
class PolygonCloudWrapperTests(unittest.TestCase):
def test_request_uses_cloud_artifact_label(self) -> None:
args = cloud_request.polygon_request.parse_args(cloud_request.DEFAULT_ARGS)
with TemporaryDirectory() as temp_dir:
args.response_dir = Path(temp_dir)
path = cloud_request.polygon_request.response_path(
args,
"01001",
"goes-tmy",
"tmy-2024",
)
self.assertEqual(
path.name,
"01001_goes-tmy_tmy-2024_cloud_response.json",
)
def test_request_reuses_legacy_cloud_response_name(self) -> None:
args = cloud_request.polygon_request.parse_args(cloud_request.DEFAULT_ARGS)
with TemporaryDirectory() as temp_dir:
args.response_dir = Path(temp_dir)
legacy_path = args.response_dir / "01001_goes-tmy_tmy-2024_ghi_response.json"
legacy_path.touch()
path = cloud_request.polygon_request.response_path(
args,
"01001",
"goes-tmy",
"tmy-2024",
)
self.assertEqual(path, legacy_path)
def test_request_user_arguments_override_cloud_defaults(self) -> None:
args = cloud_request.polygon_request.parse_args(
[
*cloud_request.DEFAULT_ARGS,
"--attributes",
"ghi",
"--requests-csv",
"custom.csv",
]
)
self.assertEqual(args.attributes, "ghi")
self.assertEqual(args.requests_csv, Path("custom.csv"))
def test_request_wrapper_passes_cloud_defaults_and_user_arguments(self) -> None:
with (
patch.object(sys, "argv", ["cloud-request", "--limit", "12"]),
patch.object(cloud_request.polygon_request, "main") as shared_main,
):
cloud_request.main()
shared_main.assert_called_once_with(
[*cloud_request.DEFAULT_ARGS, "--limit", "12"],
description=cloud_request.__doc__,
)
def test_download_wrapper_passes_cloud_defaults_and_user_arguments(self) -> None:
with (
patch.object(sys, "argv", ["cloud-download", "--overwrite"]),
patch.object(cloud_download.polygon_download, "main") as shared_main,
):
cloud_download.main()
shared_main.assert_called_once_with(
[*cloud_download.DEFAULT_ARGS, "--overwrite"],
description=cloud_download.__doc__,
)
def test_download_user_arguments_override_cloud_defaults(self) -> None:
args = cloud_download.polygon_download.parse_args(
[
*cloud_download.DEFAULT_ARGS,
"--output-dir",
"custom-archives",
]
)
self.assertEqual(args.output_dir, Path("custom-archives"))
class PolygonGhiWrapperTests(unittest.TestCase):
def test_request_uses_ghi_artifact_label(self) -> None:
args = ghi_request.polygon_request.parse_args(ghi_request.DEFAULT_ARGS)
with TemporaryDirectory() as temp_dir:
args.response_dir = Path(temp_dir)
path = ghi_request.polygon_request.response_path(
args,
"01001",
"goes-tmy",
"tmy-2024",
)
self.assertEqual(
path.name,
"01001_goes-tmy_tmy-2024_ghi_response.json",
)
def test_request_wrapper_passes_ghi_defaults_and_user_arguments(self) -> None:
with (
patch.object(sys, "argv", ["ghi-request", "--limit", "12"]),
patch.object(ghi_request.polygon_request, "main") as shared_main,
):
ghi_request.main()
shared_main.assert_called_once_with(
[*ghi_request.DEFAULT_ARGS, "--limit", "12"],
description=ghi_request.__doc__,
)
def test_request_user_arguments_override_ghi_defaults(self) -> None:
args = ghi_request.polygon_request.parse_args(
[
*ghi_request.DEFAULT_ARGS,
"--attributes",
"ghi,clearsky_ghi",
"--requests-csv",
"custom.csv",
]
)
self.assertEqual(args.attributes, "ghi,clearsky_ghi")
self.assertEqual(args.requests_csv, Path("custom.csv"))
def test_download_wrapper_passes_ghi_defaults_and_user_arguments(self) -> None:
with (
patch.object(sys, "argv", ["ghi-download", "--overwrite"]),
patch.object(ghi_download.polygon_download, "main") as shared_main,
):
ghi_download.main()
shared_main.assert_called_once_with(
[*ghi_download.DEFAULT_ARGS, "--overwrite"],
description=ghi_download.__doc__,
)
def test_download_user_arguments_override_ghi_defaults(self) -> None:
args = ghi_download.polygon_download.parse_args(
[
*ghi_download.DEFAULT_ARGS,
"--output-dir",
"custom-archives",
]
)
self.assertEqual(args.output_dir, Path("custom-archives"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,355 @@
from __future__ import annotations
import sys
import unittest
import csv
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import call, patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from request_nsrdb_county_polygon_archives import ( # noqa: E402
ArchiveQueueMonitor,
CountyRequestEvent,
CountyRequestState,
CountyRequestStateMachine,
EXCEPTIONAL_POLYGON_WAIT,
InvalidCountyRequestTransition,
LARGE_POLYGON_WAIT,
LocalQueueCapacityError,
MEDIUM_POLYGON_WAIT,
SMALL_POLYGON_WAIT,
)
class CountyRequestStateMachineTests(unittest.TestCase):
def test_successful_request_lifecycle(self) -> None:
machine = CountyRequestStateMachine("01001 Autauga County, AL")
machine.transition(CountyRequestEvent.START)
machine.transition(CountyRequestEvent.REQUEST_SUCCEEDED)
self.assertEqual(machine.state, CountyRequestState.REQUESTED)
self.assertEqual(
machine.history,
[
CountyRequestState.PENDING,
CountyRequestState.SUBMITTING,
CountyRequestState.REQUESTED,
],
)
def test_retry_returns_to_submitting(self) -> None:
machine = CountyRequestStateMachine("01001 Autauga County, AL")
machine.transition(CountyRequestEvent.START)
machine.transition(CountyRequestEvent.RETRY_REQUIRED)
machine.transition(CountyRequestEvent.RETRY_STARTED)
self.assertEqual(machine.state, CountyRequestState.SUBMITTING)
def test_existing_request_is_skipped(self) -> None:
machine = CountyRequestStateMachine("01001 Autauga County, AL")
machine.transition(CountyRequestEvent.EXISTING_REQUEST_FOUND)
self.assertEqual(machine.state, CountyRequestState.SKIPPED)
def test_invalid_transition_is_rejected(self) -> None:
machine = CountyRequestStateMachine("01001 Autauga County, AL")
with self.assertRaisesRegex(
InvalidCountyRequestTransition,
"request_succeeded while pending",
):
machine.transition(CountyRequestEvent.REQUEST_SUCCEEDED)
class ArchiveQueueMonitorTests(unittest.TestCase):
def make_monitor(self) -> ArchiveQueueMonitor:
temp_dir = TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
return ArchiveQueueMonitor(Path(temp_dir.name), timeout=1, workers=1)
def test_dynamic_wait_uses_largest_tracked_polygon(self) -> None:
monitor = self.make_monitor()
for site_count, expected_wait in (
(110, SMALL_POLYGON_WAIT),
(111, MEDIUM_POLYGON_WAIT),
(250, MEDIUM_POLYGON_WAIT),
(251, LARGE_POLYGON_WAIT),
(550, LARGE_POLYGON_WAIT),
(551, EXCEPTIONAL_POLYGON_WAIT),
):
monitor.tracked_site_counts = {"https://example.com/job.zip": site_count}
self.assertEqual(monitor.dynamic_wait_seconds(), expected_wait)
def test_downloaded_archive_is_not_reloaded_as_pending(self) -> None:
temp_dir = TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
manifest_dir = Path(temp_dir.name)
response_dir = manifest_dir / "polygon_cloud_request_responses"
archive_dir = manifest_dir / "polygon_cloud_archives"
response_dir.mkdir()
archive_dir.mkdir()
response_path = response_dir / "01001_goes-tmy_tmy-2024_cloud_response.json"
archive_path = archive_dir / "01001_goes-tmy_tmy-2024_cloud.zip"
response_path.write_text("{}", encoding="utf-8")
archive_path.write_bytes(b"downloaded")
manifest_path = manifest_dir / "county_polygon_cloud_request_manifest.csv"
with manifest_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(
handle,
fieldnames=[
"county_fips",
"site_count",
"download_url",
"response_json",
"submitted_at_utc",
],
)
writer.writeheader()
writer.writerow(
{
"county_fips": "01001",
"site_count": "100",
"download_url": "https://example.amazonaws.com/job.zip",
"response_json": str(response_path),
"submitted_at_utc": datetime.now(timezone.utc).isoformat(),
}
)
monitor = ArchiveQueueMonitor(manifest_dir, timeout=1, workers=1)
with patch(
"request_nsrdb_county_polygon_archives.archive_download_status",
return_value="pending",
) as status_mock:
counts = monitor.summarize()
self.assertEqual(counts["pending"], 0)
self.assertEqual(counts["total"], 0)
status_mock.assert_not_called()
def test_historical_s3_403_does_not_consume_a_queue_slot(self) -> None:
monitor = self.make_monitor()
url = "https://example.amazonaws.com/historical.zip"
monitor.jobs[url] = "01001"
with patch(
"request_nsrdb_county_polygon_archives.archive_download_status",
return_value="pending",
):
counts = monitor.summarize()
self.assertEqual(counts["pending"], 0)
self.assertEqual(counts["ambiguous_403"], 1)
def test_current_process_s3_403_remains_pending(self) -> None:
monitor = self.make_monitor()
url = "https://example.amazonaws.com/current.zip"
monitor.record_submission(
{
"county_fips": "01001",
"tile_id": "",
"site_count": "100",
"download_url": url,
}
)
with patch(
"request_nsrdb_county_polygon_archives.archive_download_status",
return_value="pending",
):
counts = monitor.summarize()
self.assertEqual(counts["pending"], 1)
self.assertEqual(counts["ambiguous_403"], 0)
def test_site_count_expires_after_recent_download_window(self) -> None:
monitor = self.make_monitor()
url = "https://example.com/job.zip"
monitor.jobs[url] = "01001"
monitor.pending_urls.add(url)
monitor.tracked_site_counts[url] = 551
with (
patch(
"request_nsrdb_county_polygon_archives.archive_download_status",
return_value="ready",
),
patch(
"request_nsrdb_county_polygon_archives.time.monotonic",
side_effect=[100.0, 100.0, 401.0],
),
):
first_counts = monitor.summarize()
second_counts = monitor.summarize()
self.assertEqual(first_counts["recently_downloaded"], 1)
self.assertEqual(second_counts["recently_downloaded"], 0)
self.assertNotIn(url, monitor.tracked_site_counts)
def test_fifteen_visible_slots_bypass_pacing_until_queue_is_full(self) -> None:
monitor = self.make_monitor()
monitor.paced_submissions = 1
counts_with_fifteen_slots = {
"pending": 5,
"recently_downloaded": 2,
"unobserved": 0,
"unknown": 0,
"ready": 0,
"total": 7,
}
counts_with_fourteen_slots = {
**counts_with_fifteen_slots,
"pending": 6,
}
full_queue_counts = {
**counts_with_fifteen_slots,
"pending": 20,
}
with (
patch.object(
monitor,
"summarize",
side_effect=[
counts_with_fifteen_slots,
counts_with_fourteen_slots,
full_queue_counts,
],
),
patch(
"request_nsrdb_county_polygon_archives.wait_for_queue",
) as wait_for_queue_mock,
):
monitor.wait_for_capacity("first", retries=0)
monitor.wait_for_capacity("second", retries=0)
with self.assertRaisesRegex(LocalQueueCapacityError, "20 possible"):
monitor.wait_for_capacity("third", retries=0)
wait_for_queue_mock.assert_not_called()
self.assertFalse(monitor.filling_open_queue)
def test_fourteen_visible_slots_still_use_recent_download_pacing(self) -> None:
monitor = self.make_monitor()
monitor.paced_submissions = 1
counts = {
"pending": 6,
"recently_downloaded": 2,
"unobserved": 0,
"unknown": 0,
"ready": 0,
"total": 8,
}
with (
patch.object(monitor, "summarize", return_value=counts),
patch(
"request_nsrdb_county_polygon_archives.wait_for_queue",
) as wait_for_queue_mock,
):
monitor.wait_for_capacity("next", retries=0)
wait_for_queue_mock.assert_called_once_with(
SMALL_POLYGON_WAIT,
"Pacing wait complete; refreshing archive status.",
)
def test_six_minute_wait_checks_queue_after_three_minutes(self) -> None:
monitor = self.make_monitor()
counts = {
"pending": 6,
"recently_downloaded": 0,
"unobserved": 0,
"unknown": 0,
"ready": 0,
"total": 6,
}
with (
patch.object(monitor, "summarize", return_value=counts) as summarize_mock,
patch(
"request_nsrdb_county_polygon_archives.time.sleep",
) as sleep_mock,
):
entered_fill_mode = monitor.wait_for_dynamic_queue(
LARGE_POLYGON_WAIT,
"Queue wait complete.",
)
self.assertFalse(entered_fill_mode)
self.assertEqual(sleep_mock.call_args_list, [call(180.0)] * 2)
summarize_mock.assert_called_once_with()
def test_six_minute_wait_enters_fill_mode_at_checkpoint(self) -> None:
monitor = self.make_monitor()
counts = {
"pending": 5,
"recently_downloaded": 0,
"unobserved": 0,
"unknown": 0,
"ready": 0,
"total": 5,
}
with (
patch.object(monitor, "summarize", return_value=counts),
patch(
"request_nsrdb_county_polygon_archives.time.sleep",
) as sleep_mock,
):
entered_fill_mode = monitor.wait_for_dynamic_queue(
LARGE_POLYGON_WAIT,
"Queue wait complete.",
)
self.assertTrue(entered_fill_mode)
self.assertTrue(monitor.filling_open_queue)
sleep_mock.assert_called_once_with(180.0)
def test_fifteen_minute_wait_checks_at_five_and_ten_minutes(self) -> None:
monitor = self.make_monitor()
counts_with_fourteen_slots = {
"pending": 6,
"recently_downloaded": 0,
"unobserved": 0,
"unknown": 0,
"ready": 0,
"total": 6,
}
counts_with_fifteen_slots = {
**counts_with_fourteen_slots,
"pending": 5,
}
with (
patch.object(
monitor,
"summarize",
side_effect=[counts_with_fourteen_slots, counts_with_fifteen_slots],
) as summarize_mock,
patch(
"request_nsrdb_county_polygon_archives.time.sleep",
) as sleep_mock,
):
entered_fill_mode = monitor.wait_for_dynamic_queue(
EXCEPTIONAL_POLYGON_WAIT,
"Queue wait complete.",
)
self.assertTrue(entered_fill_mode)
self.assertTrue(monitor.filling_open_queue)
self.assertEqual(sleep_mock.call_args_list, [call(300.0)] * 2)
self.assertEqual(summarize_mock.call_count, 2)
if __name__ == "__main__":
unittest.main()