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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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일치를
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`

Expand Down Expand Up @@ -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()`를 사용할 수 있습니다.

Expand All @@ -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을 넘을 수 있는 필드는 그대로 둡니다.

---

## 발표시각 규칙
Expand Down
3 changes: 3 additions & 0 deletions src/kma/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -103,6 +104,7 @@
"KmaRequestError",
"KmaServerError",
"KMA_DATA_GOKR_DATASETS",
"KMA_MISSING_ABS_THRESHOLD",
"LatLon",
"MidForecastItem",
"ObservedPrecipitationType",
Expand All @@ -117,6 +119,7 @@
"api_catalog",
"apihub_endpoint_catalog",
"debug_error",
"is_missing",
"iter_pages",
"jsonable",
"kma_grid_to_wgs84",
Expand Down
28 changes: 28 additions & 0 deletions src/kma/_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from __future__ import annotations

import math

from .missing import is_missing


def float_or_none(value: object) -> float | None:
if value is None:
Expand All @@ -22,6 +26,30 @@ 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``).

``NaN``/``Infinity``는 정수로 바꿀 수 없으므로 예외 대신 ``None``을 반환한다.
"""

number = kma_value_or_none(value)
if number is None or not math.isfinite(number):
return None
return int(number)


def str_or_none(value: object) -> str | None:
if value is None:
return None
Expand Down
30 changes: 23 additions & 7 deletions src/kma/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/kma/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re

from .enums import KmaEndpoint, WeatherCategory, enum_value
from .missing import is_missing

SKY_LABELS = {
"1": "맑음",
Expand Down Expand Up @@ -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:
Expand Down
64 changes: 64 additions & 0 deletions src/kma/missing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""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
# 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
14 changes: 10 additions & 4 deletions src/kma/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,13 +98,19 @@ 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
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)
Expand Down Expand Up @@ -160,7 +166,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
Expand Down
Loading
Loading