From d864f34439a22e2f19070cd723d079da689a68c6 Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:57:51 +0900 Subject: [PATCH 1/6] test: KMA Missing sentinels (|v|>=900) must not reach typed fields (RED) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RuoPRjkdpZNYjTZ1RYQoHj --- tests/test_missing_values.py | 257 +++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 tests/test_missing_values.py diff --git a/tests/test_missing_values.py b/tests/test_missing_values.py new file mode 100644 index 0000000..1314a1c --- /dev/null +++ b/tests/test_missing_values.py @@ -0,0 +1,257 @@ +"""KMA Missing sentinels (|v| >= 900) in 단기예보 observation/forecast values. + +The 단기예보 조회서비스 활용가이드 says a value of +900 or more / -900 or less is +"Missing" (no instrument / no observation). Production getUltraSrtNcst answered a +station with no observation with ``REH/VEC -998``, ``RN1/WSD/UUU/VVV -998.9`` and +``T1H -999`` -- the payloads below are that shape. +""" + +from __future__ import annotations + +import math +from datetime import datetime +from decimal import Decimal +from typing import Any + +import pytest + +import kma +from kma import is_missing +from kma.client import KmaClient +from kma.codes import normalize_value +from kma.time_utils import KST +from kma.timeline import pivot_forecast_items + + +class _Response: + status_code = 200 + + def __init__(self, payload: dict[str, Any]) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, Any]: + return self._payload + + +class _Session: + def __init__(self, payload: dict[str, Any]) -> None: + self.payload = payload + + async def get(self, url: str, *, params: dict[str, Any], timeout: float) -> _Response: + del url, params, timeout + return _Response(self.payload) + + +def _payload(items: list[dict[str, Any]]) -> dict[str, Any]: + return { + "response": { + "header": {"resultCode": "00", "resultMsg": "NORMAL_SERVICE"}, + "body": { + "dataType": "JSON", + "pageNo": 1, + "numOfRows": 1000, + "totalCount": len(items), + "items": {"item": items}, + }, + } + } + + +def _ncst(category: str, value: str) -> dict[str, Any]: + return { + "baseDate": "20261005", + "baseTime": "1400", + "category": category, + "nx": 60, + "ny": 127, + "obsrValue": value, + } + + +def _fcst(category: str, value: str) -> dict[str, Any]: + return { + "baseDate": "20261005", + "baseTime": "1400", + "category": category, + "fcstDate": "20261005", + "fcstTime": "1500", + "fcstValue": value, + "nx": 60, + "ny": 127, + } + + +#: The exact sentinels production answered with for a station with no observation. +_PROD_SENTINEL_NCST = [ + _ncst("PTY", "0"), + _ncst("REH", "-998"), + _ncst("RN1", "-998.9"), + _ncst("T1H", "-999"), + _ncst("UUU", "-998.9"), + _ncst("VEC", "-998"), + _ncst("VVV", "-998.9"), + _ncst("WSD", "-998.9"), +] + +_WHEN = datetime(2026, 10, 5, 14, 45, tzinfo=KST) + + +async def test_now_reports_missing_sentinels_as_none_not_as_measurements() -> None: + client = KmaClient("decoded-key", session=_Session(_payload(_PROD_SENTINEL_NCST))) + + snapshot = await client.now(nx=60, ny=127, when=_WHEN) + + assert snapshot.temperature is None + assert snapshot.humidity is None + assert snapshot.wind_speed is None + assert snapshot.wind_direction is None + assert snapshot.precipitation is None + # The raw strings stay available for callers that want to see the sentinel. + assert snapshot.raw["by_category"]["T1H"] == "-999" + assert snapshot.raw["by_category"]["RN1"] == "-998.9" + # PTY was observed ("0" = 없음) and is unaffected. + assert snapshot.precipitation_label == "없음" + + +async def test_now_keeps_real_values_next_to_a_missing_one() -> None: + items = [ + _ncst("T1H", "18.4"), + _ncst("REH", "-998"), + _ncst("WSD", "3.1"), + _ncst("VEC", "359"), + _ncst("RN1", "강수없음"), + _ncst("PTY", "0"), + ] + client = KmaClient("decoded-key", session=_Session(_payload(items))) + + snapshot = await client.now(nx=60, ny=127, when=_WHEN) + + assert snapshot.temperature == 18.4 + assert snapshot.humidity is None + assert snapshot.wind_speed == 3.1 + assert snapshot.wind_direction == 359 + assert snapshot.precipitation == 0.0 + + +@pytest.mark.parametrize( + ("value", "expected"), + [("-899.9", -899.9), ("899.9", 899.9), ("-40.5", -40.5), ("0", 0.0)], +) +async def test_now_keeps_values_just_inside_the_threshold(value: str, expected: float) -> None: + client = KmaClient("decoded-key", session=_Session(_payload([_ncst("T1H", value)]))) + + snapshot = await client.now(nx=60, ny=127, when=_WHEN) + + assert snapshot.temperature == expected + + +async def test_forecast_reports_missing_sentinels_as_none() -> None: + items = [ + _fcst("TMP", "-999"), + _fcst("REH", "-998"), + _fcst("WSD", "-998.9"), + _fcst("POP", "900"), + _fcst("PCP", "-998.9"), + _fcst("SKY", "-998"), + _fcst("TMN", "12.0"), + ] + client = KmaClient("decoded-key", session=_Session(_payload(items))) + + forecast = await client.forecast(nx=60, ny=127, when=_WHEN) + + values = {str(item.category): item.value for item in forecast} + assert values == { + "TMP": None, + "REH": None, + "WSD": None, + "POP": None, + "PCP": None, + "SKY": None, + "TMN": 12.0, + } + assert {str(item.category): item.raw["fcstValue"] for item in forecast}["TMP"] == "-999" + timepoint = pivot_forecast_items(forecast)[0] + assert timepoint.value("TMP") is None + assert timepoint.value("TMN") == 12.0 + + +def test_normalize_value_treats_sentinels_as_missing_but_keeps_labels() -> None: + assert normalize_value("T1H", "-999") is None + assert normalize_value("UUU", "-998.9") is None + assert normalize_value("PCP", "-998.9") is None + assert normalize_value("TMP", " ") is None + assert normalize_value("PCP", "1.0mm 미만") == "1.0mm 미만" + assert normalize_value("PCP", "50.0mm 이상") == "50.0mm 이상" + assert normalize_value("TMP", "-12.3") == -12.3 + + +@pytest.mark.parametrize( + "value", + [ + None, + "", + " ", + "\t\n", + "-998", + "-998.9", + "-999", + " -999 ", + "900", + "-900", + "+900", + "999.0", + "9e2", + "1e400", + -998.9, + -999, + 900.0, + Decimal("-998.9"), + Decimal("900"), + ], +) +def test_is_missing_true(value: object) -> None: + assert is_missing(value) is True # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "value", + [ + "0", + "0.0", + "-899.9", + "899.99", + "18.4", + "강수없음", + "1.0mm 미만", + "30.0~50.0mm", + "50.0mm 이상", + "abc", + "nan", + "NaN", + "inf", + "-Infinity", + "sNaN", + 0, + -899.9, + math.nan, + math.inf, + -math.inf, + Decimal("NaN"), + Decimal("-Infinity"), + Decimal("899.9"), + ], +) +def test_is_missing_false(value: object) -> None: + # NaN/Infinity are not KMA sentinels; whether they are *invalid* is the + # caller's decision. Non-numeric labels are values, not missing. + assert is_missing(value) is False # type: ignore[arg-type] + + +def test_is_missing_is_public_and_documented() -> None: + assert "is_missing" in kma.__all__ + assert kma.is_missing is is_missing + assert is_missing.__doc__ and "900" in is_missing.__doc__ + assert kma.KMA_MISSING_ABS_THRESHOLD == Decimal("900") From 04bfd0d3a7f0e28cbf5d58d2cdf5852b4331342f Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:59:55 +0900 Subject: [PATCH 2/6] fix: treat KMA Missing sentinels (|v|>=900) as None in short-term forecast values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getUltraSrtNcst answered a station with no observation with REH/VEC -998, RN1/WSD/UUU/VVV -998.9 and T1H -999; the 단기예보 guide defines +900/-900 and beyond as Missing, but float_or_none passed them straight into typed fields (WeatherSnapshot.humidity == -998). - add public kma.is_missing() / KMA_MISSING_ABS_THRESHOLD (None, blank and finite |v|>=900 are missing; NaN/Inf and non-numeric labels are not) - WeatherSnapshot temperature/humidity/wind_speed/wind_direction/precipitation use the KMA-only kma_value_or_none/kma_int_or_none; shared parsers unchanged - normalize_value returns None for missing, so ForecastItem.value / BeachForecastItem.value / ForecastTimepoint.values are now Optional - ASOS pressure, beach tide level/buoys and grid coordinates keep their values Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RuoPRjkdpZNYjTZ1RYQoHj --- CHANGELOG.md | 16 ++++++++++++ README.md | 35 +++++++++++++++++++++++-- src/kma/__init__.py | 3 +++ src/kma/_parsing.py | 23 +++++++++++++++++ src/kma/client.py | 30 +++++++++++++++++----- src/kma/codes.py | 11 +++++++- src/kma/missing.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ src/kma/models.py | 6 ++--- 8 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 src/kma/missing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd22c8..88cc98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ ### 수정 +- 단기예보 Missing 센티널(활용가이드: `+900` 이상·`-900` 이하)이 측정값으로 실리던 문제 수정. + 관측이 없는 격자의 `getUltraSrtNcst`가 `REH/VEC -998`, `RN1/WSD/UUU/VVV -998.9`, + `T1H -999`를 돌려주면 `WeatherSnapshot.humidity == -998`, `temperature == -999.0`처럼 + 그대로 들어갔다. 이제 `WeatherSnapshot`의 `temperature`/`humidity`/`wind_speed`/ + `wind_direction`/`precipitation`과 `ForecastItem.value`/`BeachForecastItem.value` + (`ForecastTimepoint.values`)는 Missing이면 `None`이다. 원문은 `raw`에 남는다. + - 새 public helper `kma.is_missing(value)`와 `kma.KMA_MISSING_ABS_THRESHOLD`(`Decimal("900")`). + `None`/빈 문자열/공백과 유한한 `abs(v) >= 900`이면 `True`, 숫자가 아닌 라벨과 + `NaN`/`Infinity`는 `False`(센티널이 아니며 유효성은 호출자 몫). + - 타입 변경: `ForecastItem.value`/`BeachForecastItem.value`는 `str | float | None`, + `ForecastTimepoint.values`는 `dict[str, str | float | None]`. 빈 `fcstValue`도 이제 `""` 대신 `None`. + 빈 `RN1` 관측은 `0.0` 대신 `None`(관측 없음은 강수 0이 아니다). + - 적용하지 않은 필드: ASOS 일·시간 자료(기압 `pa`/`ps`는 정상값이 900 hPa를 넘는다, 별도 서비스), + 해수욕장 파고·수온·조위(`tilevel`은 cm 단위로 900을 넘을 수 있다, 별도 관측망), 격자 `nx`/`ny`. + 공용 `float_or_none`/`int_or_none`은 그대로 두고 단기예보 전용 `kma_value_or_none`/ + `kma_int_or_none`을 추가해 해당 필드에만 쓴다. - `getUltraSrtNcst`/`getUltraSrtFcst`/`getVilageFcst` 응답이 요청한 페이지 하나를 넘으면 `KmaClient._fetch_items()`가 재시도 불가 `KmaParseError("KMA response has more items than the requested page size")`로 즉시 실패하던 문제 수정. 단기예보(`getVilageFcst`)는 3일치를 diff --git a/README.md b/README.md index 757a1f1..6f0e611 100644 --- a/README.md +++ b/README.md @@ -518,7 +518,7 @@ class ForecastItem(BaseModel): nx: int ny: int category: WeatherCategory | str - value: str | float + value: str | float | None label: str | None @property @@ -536,7 +536,7 @@ class ForecastItem(BaseModel): `ForecastItem.category`는 알려진 category일 때 `WeatherCategory` enum으로 들어갑니다. `WeatherCategory`는 `str` 기반 enum이라 `"TMP"` 같은 원문 문자열과 비교할 수 있고 JSON 직렬화도 자연스럽게 동작합니다. 알 수 없는 새 category는 원문 문자열을 보존합니다. -`ForecastItem.value`는 숫자로 안전하게 해석되는 값만 `float`가 됩니다. `PCP`, `SNO` 범주 문자열은 원문을 보존합니다. +`ForecastItem.value`는 숫자로 안전하게 해석되는 값만 `float`가 됩니다. `PCP`, `SNO` 범주 문자열은 원문을 보존합니다. 값이 비었거나 Missing 센티널(아래 "Missing 값")이면 `None`이며, 원문은 `raw["fcstValue"]`에 남습니다. ### `ForecastTimepoint` @@ -621,6 +621,7 @@ KMA API는 대부분의 값을 문자열로 반환합니다. `kma`는 사용자 | `SKY`, `PTY` 코드 | `str` 값 + `label` | `"1"` -> `"맑음"` | | `PCP`, `SNO` 범주 | `str` | `"1.0mm 미만"` 보존 | | 빈 값 또는 파싱 불가 값 | `None` 또는 원문 | 모델별로 안전하게 처리 | +| 단기예보 Missing 센티널(`abs(v) >= 900`) | `None` | `"-998.9"` -> `None` | 강수량/적설량 범주를 대표값으로 바꾸고 싶을 때는 `kma.codes.parse_amount()`를 사용할 수 있습니다. @@ -632,6 +633,36 @@ parse_amount("30.0~50.0mm") # 40.0 parse_amount("강수없음") # 0.0 ``` +### Missing 값 + +기상청 단기예보 조회서비스 활용가이드는 관측·예보값이 `+900` 이상 또는 `-900` 이하이면 +**Missing**(관측장비 없음·결측)으로 정의합니다. 실제로 관측이 없는 격자의 `getUltraSrtNcst`는 +`REH/VEC -998`, `RN1/WSD/UUU/VVV -998.9`, `T1H -999`를 돌려줍니다. `kma`는 이 값을 측정값으로 +싣지 않습니다. + +- `WeatherSnapshot`의 `temperature`/`humidity`/`wind_speed`/`wind_direction`/`precipitation`은 + Missing이면 `None`입니다. +- `ForecastItem.value`/`BeachForecastItem.value`/`ForecastTimepoint.values`는 Missing이거나 빈 값이면 + `None`입니다. +- 원문 문자열은 `raw`에 그대로 남습니다. + +원문 `obsrValue`/`fcstValue`를 직접 다룬다면 `kma.is_missing()`을 쓰세요. + +```python +from kma import is_missing + +is_missing("-998.9") # True (센티널) +is_missing(" ") # True (빈 값) +is_missing(None) # True +is_missing("-899.9") # False +is_missing("1.0mm 미만") # False (라벨은 값이 있는 것) +is_missing("NaN") # False (센티널 아님 -- 유효성은 호출자가 판단) +``` + +이 규칙은 단기예보 계열(`getUltraSrtNcst`/`getUltraSrtFcst`/`getVilageFcst`, 같은 category 체계의 +해수욕장 예보)의 값에만 적용합니다. ASOS 기압(`pa`/`ps`, hPa), 해수욕장 조위(`tilevel`), +격자·지점 번호처럼 정상값이 900을 넘을 수 있는 필드는 그대로 둡니다. + --- ## 발표시각 규칙 diff --git a/src/kma/__init__.py b/src/kma/__init__.py index f27c1db..8db583f 100644 --- a/src/kma/__init__.py +++ b/src/kma/__init__.py @@ -42,6 +42,7 @@ from .grid import kma_grid_to_wgs84, to_grid, to_latlon, wgs84_to_kma_grid from .locations import GridPoint, LatLon, normalize_location from .metadata import ResponseMetadata, make_cache_key, sanitize_request_params +from .missing import KMA_MISSING_ABS_THRESHOLD, is_missing from .models import ( AsosDailyItem, AsosHourlyItem, @@ -103,6 +104,7 @@ "KmaRequestError", "KmaServerError", "KMA_DATA_GOKR_DATASETS", + "KMA_MISSING_ABS_THRESHOLD", "LatLon", "MidForecastItem", "ObservedPrecipitationType", @@ -117,6 +119,7 @@ "api_catalog", "apihub_endpoint_catalog", "debug_error", + "is_missing", "iter_pages", "jsonable", "kma_grid_to_wgs84", diff --git a/src/kma/_parsing.py b/src/kma/_parsing.py index 6213343..9ddee38 100644 --- a/src/kma/_parsing.py +++ b/src/kma/_parsing.py @@ -2,6 +2,8 @@ from __future__ import annotations +from .missing import is_missing + def float_or_none(value: object) -> float | None: if value is None: @@ -22,6 +24,27 @@ def int_or_none(value: object) -> int | None: return int(number) +def kma_value_or_none(value: object) -> float | None: + """단기예보 ``obsrValue``/``fcstValue``용 float 변환. Missing 센티널은 ``None``. + + `float_or_none`과 달리 ``abs(value) >= 900``(활용가이드 Missing)을 ``None``으로 + 돌린다. 기압·조위·좌표처럼 정상값이 900을 넘는 필드에는 `float_or_none`을 쓴다. + """ + + if isinstance(value, (str, int, float)) and is_missing(value): + return None + return float_or_none(value) + + +def kma_int_or_none(value: object) -> int | None: + """`kma_value_or_none`의 정수판(습도 ``REH``, 풍향 ``VEC``).""" + + number = kma_value_or_none(value) + if number is None: + return None + return int(number) + + def str_or_none(value: object) -> str | None: if value is None: return None diff --git a/src/kma/client.py b/src/kma/client.py index 8a71a5a..e4a3b8f 100644 --- a/src/kma/client.py +++ b/src/kma/client.py @@ -21,8 +21,8 @@ raise_for_kma_xml_error_body, validate_async_session, ) -from ._parsing import float_or_none as _float_or_none -from ._parsing import int_or_none as _int_or_none +from ._parsing import kma_int_or_none as _kma_int_or_none +from ._parsing import kma_value_or_none as _kma_value_or_none from ._ratelimit import AsyncTokenBucket from ._redact import credential_values, redact_exception from .codes import label_for, normalize_value, parse_amount @@ -31,6 +31,7 @@ from .grid import validate_grid from .locations import LocationInput, normalize_location from .metadata import ResponseMetadata, make_response_metadata +from .missing import is_missing from .models import ForecastItem, WeatherSnapshot from .pagination import has_next_page from .time_utils import ( @@ -156,11 +157,18 @@ async def now( observed_at=parse_kma_datetime(base_date, base_time), nx=grid_x, ny=grid_y, - temperature=_float_or_none(by_category.get(WeatherCategory.CURRENT_TEMPERATURE.value)), - humidity=_int_or_none(by_category.get(WeatherCategory.HUMIDITY.value)), - wind_speed=_float_or_none(by_category.get(WeatherCategory.WIND_SPEED.value)), - wind_direction=_int_or_none(by_category.get(WeatherCategory.WIND_DIRECTION.value)), - precipitation=parse_amount(by_category.get(WeatherCategory.ONE_HOUR_RAIN.value)), + # 활용가이드: |v| >= 900은 Missing(관측 없음) -- 측정값으로 싣지 않는다. + temperature=_kma_value_or_none( + by_category.get(WeatherCategory.CURRENT_TEMPERATURE.value) + ), + humidity=_kma_int_or_none(by_category.get(WeatherCategory.HUMIDITY.value)), + wind_speed=_kma_value_or_none(by_category.get(WeatherCategory.WIND_SPEED.value)), + wind_direction=_kma_int_or_none( + by_category.get(WeatherCategory.WIND_DIRECTION.value) + ), + precipitation=_observed_amount( + by_category.get(WeatherCategory.ONE_HOUR_RAIN.value) + ), sky_label=label_for( WeatherCategory.SKY, by_category.get(WeatherCategory.SKY.value), @@ -561,6 +569,14 @@ def _page_items(body: Mapping[str, Any], endpoint_name: str) -> list[Mapping[str return items +def _observed_amount(value: object) -> float | None: + """``RN1`` 관측값. Missing 센티널·빈 값이면 ``None``, 그 밖에는 `parse_amount`.""" + + if value is not None and is_missing(str(value)): + return None + return parse_amount(value) + + def _forecast_item( item: Mapping[str, Any], endpoint: str | KmaEndpoint, diff --git a/src/kma/codes.py b/src/kma/codes.py index 9523072..22abdd0 100644 --- a/src/kma/codes.py +++ b/src/kma/codes.py @@ -5,6 +5,7 @@ import re from .enums import KmaEndpoint, WeatherCategory, enum_value +from .missing import is_missing SKY_LABELS = { "1": "맑음", @@ -81,9 +82,17 @@ def label_for( return None -def normalize_value(category: str | WeatherCategory, value: object) -> str | float: +def normalize_value(category: str | WeatherCategory, value: object) -> str | float | None: + """단기예보 ``fcstValue``를 category에 맞게 정규화합니다. + + 값이 없거나(빈 문자열·공백) 활용가이드 Missing 센티널(``abs(v) >= 900``)이면 + category와 무관하게 ``None``을 반환합니다. 원문은 호출자가 ``raw``에 보존합니다. + """ + category_code = enum_value(category) raw = "" if value is None else str(value).strip() + if is_missing(raw): + return None if category_code in {WeatherCategory.PRECIPITATION.value, WeatherCategory.SNOW.value}: return raw if category_code in _NUMERIC_CATEGORIES: diff --git a/src/kma/missing.py b/src/kma/missing.py new file mode 100644 index 0000000..353d04e --- /dev/null +++ b/src/kma/missing.py @@ -0,0 +1,62 @@ +"""KMA 단기예보 Missing 센티널 판정. + +기상청 단기예보 조회서비스(초단기실황 ``getUltraSrtNcst``, 초단기예보 +``getUltraSrtFcst``, 단기예보 ``getVilageFcst``) 활용가이드는 관측·예보값이 +``+900`` 이상이거나 ``-900`` 이하이면 **Missing**(관측장비 없음·결측)이라고 +정의한다. 실제 운영 응답에서는 관측이 없는 격자에 대해 ``REH/VEC -998``, +``RN1/WSD/UUU/VVV -998.9``, ``T1H -999``가 왔다. + +이 규칙은 위 단기예보 계열의 ``obsrValue``/``fcstValue``에만 적용된다. 기압(hPa), +조위(cm), 격자·지점 번호처럼 정상값이 900을 넘을 수 있는 필드에는 쓰지 않는다. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation + +__all__ = ["KMA_MISSING_ABS_THRESHOLD", "is_missing"] + +#: 절댓값이 이 값 이상이면 KMA Missing 센티널이다(활용가이드 "+900 이상, -900 이하"). +KMA_MISSING_ABS_THRESHOLD = Decimal("900") + + +def is_missing(value: str | float | Decimal | None) -> bool: + """KMA 단기예보 관측·예보값이 "값 없음"인지 판정합니다. + + 다음이면 ``True``: + + - ``None``, 빈 문자열, 공백만 있는 문자열 + - 유한한 숫자(또는 숫자 문자열)로서 ``abs(value) >= 900`` -- 활용가이드의 + Missing 센티널(예: ``"-998"``, ``"-998.9"``, ``"-999"``, ``900``). + 문자열은 ``Decimal``로 읽으므로 ``"1e400"``처럼 float로는 넘치는 값도 + 센티널로 본다. + + 다음은 ``False``: + + - 범위 안의 숫자(``"-899.9"``, ``"18.4"``, ``0``) + - 숫자가 아닌 라벨(``"강수없음"``, ``"1.0mm 미만"``, ``"50.0mm 이상"``) -- + 값이 있는 것이며 해석은 호출자 몫이다. + - ``NaN``/``Infinity`` -- 센티널이 아니다. 유효하지 않은 값으로 다룰지는 + 호출자가 정한다. + + 단기예보 계열(``getUltraSrtNcst``/``getUltraSrtFcst``/``getVilageFcst``와 같은 + category 체계를 쓰는 해수욕장 예보)의 ``obsrValue``/``fcstValue``에만 쓴다. + """ + + if value is None: + return True + if isinstance(value, bool): + return False + if isinstance(value, Decimal): + number = value + elif isinstance(value, (int, float)): + number = Decimal(value) + else: + text = str(value).strip() + if not text: + return True + try: + number = Decimal(text) + except (InvalidOperation, ValueError): + return False + return number.is_finite() and abs(number) >= KMA_MISSING_ABS_THRESHOLD diff --git a/src/kma/models.py b/src/kma/models.py index 833eafe..d94abd1 100644 --- a/src/kma/models.py +++ b/src/kma/models.py @@ -57,7 +57,7 @@ class ForecastItem(kmaModel): nx: int ny: int category: WeatherCategory | str - value: str | float + value: str | float | None label: str | None raw: dict[str, Any] = Field(default_factory=dict) metadata: ResponseMetadata | None = None @@ -104,7 +104,7 @@ class ForecastTimepoint(kmaModel): forecast_at: datetime nx: int ny: int - values: dict[str, str | float] = Field(default_factory=dict) + values: dict[str, str | float | None] = Field(default_factory=dict) labels: dict[str, str] = Field(default_factory=dict) units: dict[str, str] = Field(default_factory=dict) raw_items: list[dict[str, Any]] = Field(default_factory=list) @@ -160,7 +160,7 @@ class BeachForecastItem(kmaModel): forecast_at: datetime beach_num: str category: WeatherCategory | str - value: str | float + value: str | float | None label: str | None nx: int | None = None ny: int | None = None From ff0e125472ab7fb696a216e233a2f2f4ca3b4a0f Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:00:37 +0900 Subject: [PATCH 3/6] test: key forecast values by category code Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RuoPRjkdpZNYjTZ1RYQoHj --- tests/test_missing_values.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_missing_values.py b/tests/test_missing_values.py index 1314a1c..b418c58 100644 --- a/tests/test_missing_values.py +++ b/tests/test_missing_values.py @@ -19,6 +19,7 @@ from kma import is_missing from kma.client import KmaClient from kma.codes import normalize_value +from kma.enums import enum_value from kma.time_utils import KST from kma.timeline import pivot_forecast_items @@ -162,7 +163,7 @@ async def test_forecast_reports_missing_sentinels_as_none() -> None: forecast = await client.forecast(nx=60, ny=127, when=_WHEN) - values = {str(item.category): item.value for item in forecast} + values = {enum_value(item.category): item.value for item in forecast} assert values == { "TMP": None, "REH": None, @@ -172,7 +173,7 @@ async def test_forecast_reports_missing_sentinels_as_none() -> None: "SKY": None, "TMN": 12.0, } - assert {str(item.category): item.raw["fcstValue"] for item in forecast}["TMP"] == "-999" + assert {enum_value(item.category): item.raw["fcstValue"] for item in forecast}["TMP"] == "-999" timepoint = pivot_forecast_items(forecast)[0] assert timepoint.value("TMP") is None assert timepoint.value("TMN") == 12.0 From f80a621da6dfaa26dd44b5bd5a48478e48a8f2e9 Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:27:13 +0900 Subject: [PATCH 4/6] test: pin is_missing overflow/rounding, non-finite ints, blank RN1, beach sentinels (RED) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RuoPRjkdpZNYjTZ1RYQoHj --- tests/test_missing_values.py | 87 ++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/tests/test_missing_values.py b/tests/test_missing_values.py index b418c58..cad8780 100644 --- a/tests/test_missing_values.py +++ b/tests/test_missing_values.py @@ -17,8 +17,10 @@ import kma from kma import is_missing +from kma._parsing import kma_int_or_none from kma.client import KmaClient from kma.codes import normalize_value +from kma.datagokr import DataGoKrClient from kma.enums import enum_value from kma.time_utils import KST from kma.timeline import pivot_forecast_items @@ -251,8 +253,87 @@ def test_is_missing_false(value: object) -> None: assert is_missing(value) is False # type: ignore[arg-type] -def test_is_missing_is_public_and_documented() -> None: - assert "is_missing" in kma.__all__ +def test_is_missing_is_exported_from_the_package_root() -> None: + assert {"is_missing", "KMA_MISSING_ABS_THRESHOLD"} <= set(kma.__all__) assert kma.is_missing is is_missing - assert is_missing.__doc__ and "900" in is_missing.__doc__ assert kma.KMA_MISSING_ABS_THRESHOLD == Decimal("900") + + +def test_is_missing_does_not_overflow_on_a_huge_exponent() -> None: + # abs() would apply the Decimal context (Emax 999999) and raise Overflow. + assert is_missing("1e1000000") is True + assert is_missing("-1e1000000") is True + assert is_missing(Decimal("1e1000000")) is True + + +def test_is_missing_does_not_round_a_value_just_below_the_threshold() -> None: + # 33 significant digits: abs() would round it to 900 at the default 28-digit precision. + assert is_missing("899.99999999999999999999999999999") is False + assert is_missing("-899.99999999999999999999999999999") is False + assert is_missing(Decimal("899.99999999999999999999999999999")) is False + + +@pytest.mark.parametrize("value", ["NaN", "nan", "Infinity", "-Infinity", "inf", math.nan, math.inf]) +def test_kma_int_or_none_returns_none_for_non_finite_values(value: object) -> None: + assert kma_int_or_none(value) is None + + +async def test_now_with_a_non_finite_humidity_does_not_raise() -> None: + items = [_ncst("T1H", "18.4"), _ncst("REH", "NaN"), _ncst("VEC", "Infinity")] + client = KmaClient("decoded-key", session=_Session(_payload(items))) + + snapshot = await client.now(nx=60, ny=127, when=_WHEN) + + assert snapshot.temperature == 18.4 + assert snapshot.humidity is None + assert snapshot.wind_direction is None + + +@pytest.mark.parametrize("blank", ["", " "]) +async def test_now_reports_a_blank_rn1_as_unknown_not_as_no_rain(blank: str) -> None: + items = [_ncst("T1H", "18.4"), _ncst("RN1", blank), _ncst("PTY", "0")] + client = KmaClient("decoded-key", session=_Session(_payload(items))) + + snapshot = await client.now(nx=60, ny=127, when=_WHEN) + + assert snapshot.precipitation is None + assert snapshot.raw["by_category"]["RN1"] == blank + + +def _beach(category: str, value: str) -> dict[str, Any]: + return { + "beachNum": "1", + "baseDate": "20261005", + "baseTime": "1400", + "category": category, + "fcstDate": "20261005", + "fcstTime": "1500", + "fcstValue": value, + "nx": "51", + "ny": "124", + } + + +@pytest.mark.parametrize("operation", ["beach_forecast", "beach_ultra_short_forecast"]) +async def test_beach_forecast_reports_missing_sentinels_as_none(operation: str) -> None: + items = [ + _beach("TMP", "-999"), + _beach("WAV", "-998.9"), + _beach("POP", "900"), + _beach("SKY", "-998"), + _beach("REH", "65"), + ] + client = DataGoKrClient("decoded-key", session=_Session(_payload(items))) + + rows = await getattr(client, operation)( + beach_num=1, base_date="20261005", base_time="1400" + ) + + assert {enum_value(row.category): row.value for row in rows} == { + "TMP": None, + "WAV": None, + "POP": None, + "SKY": None, + "REH": 65.0, + } + assert {enum_value(row.category): row.raw["fcstValue"] for row in rows}["WAV"] == "-998.9" From 2ecbc60b10c0cd20c3f9766239111086c95725fb Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:28:02 +0900 Subject: [PATCH 5/6] fix: exact copy_abs in is_missing; kma_int_or_none returns None for NaN/Inf; document None in ForecastTimepoint.values - abs(Decimal) applies the context: Overflow on 1e1000000 and rounding 899.999...(33 digits) up to 900. copy_abs() is exact. - kma_int_or_none("NaN"/"Infinity") raised ValueError from int(); a non-finite REH/VEC now gives None instead of failing now(). - ForecastTimepoint docstring: a present key with None means present but missing/blank; an absent key means no row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RuoPRjkdpZNYjTZ1RYQoHj --- src/kma/_parsing.py | 9 +++++++-- src/kma/missing.py | 4 +++- src/kma/models.py | 8 +++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/kma/_parsing.py b/src/kma/_parsing.py index 9ddee38..d5df0cb 100644 --- a/src/kma/_parsing.py +++ b/src/kma/_parsing.py @@ -2,6 +2,8 @@ from __future__ import annotations +import math + from .missing import is_missing @@ -37,10 +39,13 @@ def kma_value_or_none(value: object) -> float | None: def kma_int_or_none(value: object) -> int | None: - """`kma_value_or_none`의 정수판(습도 ``REH``, 풍향 ``VEC``).""" + """`kma_value_or_none`의 정수판(습도 ``REH``, 풍향 ``VEC``). + + ``NaN``/``Infinity``는 정수로 바꿀 수 없으므로 예외 대신 ``None``을 반환한다. + """ number = kma_value_or_none(value) - if number is None: + if number is None or not math.isfinite(number): return None return int(number) diff --git a/src/kma/missing.py b/src/kma/missing.py index 353d04e..9a7e38c 100644 --- a/src/kma/missing.py +++ b/src/kma/missing.py @@ -59,4 +59,6 @@ def is_missing(value: str | float | Decimal | None) -> bool: number = Decimal(text) except (InvalidOperation, ValueError): return False - return number.is_finite() and abs(number) >= KMA_MISSING_ABS_THRESHOLD + # copy_abs() is exact: abs() would apply the Decimal context, raising Overflow + # on "1e1000000" and rounding 33-digit values just below 900 up to 900. + return number.is_finite() and number.copy_abs() >= KMA_MISSING_ABS_THRESHOLD diff --git a/src/kma/models.py b/src/kma/models.py index d94abd1..9c4bfdd 100644 --- a/src/kma/models.py +++ b/src/kma/models.py @@ -98,7 +98,13 @@ def latlon(self) -> LatLon: class ForecastTimepoint(kmaModel): - """예보 row를 `forecast_at` 기준으로 피벗한 시간대별 예보 묶음.""" + """예보 row를 `forecast_at` 기준으로 피벗한 시간대별 예보 묶음. + + `values`에 key가 없으면 그 category row가 응답에 없었다는 뜻이다. key가 있고 + 값이 ``None``이면 row는 있었지만 값이 비었거나 KMA Missing 센티널 + (``abs(v) >= 900``, `kma.is_missing`)이었다는 뜻이다. 원문은 `raw_items`에 남는다. + `value()`는 두 경우 모두 ``None``을 돌려주므로, 구분하려면 ``category in values``를 본다. + """ base_at: datetime | None = None forecast_at: datetime From 7e5b4757fef425442ba60adc250f4ab93b580953 Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:28:53 +0900 Subject: [PATCH 6/6] test: wrap long parametrize line (E501) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RuoPRjkdpZNYjTZ1RYQoHj --- tests/test_missing_values.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_missing_values.py b/tests/test_missing_values.py index cad8780..1b536cd 100644 --- a/tests/test_missing_values.py +++ b/tests/test_missing_values.py @@ -273,7 +273,9 @@ def test_is_missing_does_not_round_a_value_just_below_the_threshold() -> None: assert is_missing(Decimal("899.99999999999999999999999999999")) is False -@pytest.mark.parametrize("value", ["NaN", "nan", "Infinity", "-Infinity", "inf", math.nan, math.inf]) +@pytest.mark.parametrize( + "value", ["NaN", "nan", "Infinity", "-Infinity", "inf", math.nan, math.inf] +) def test_kma_int_or_none_returns_none_for_non_finite_values(value: object) -> None: assert kma_int_or_none(value) is None