Honor documented fine-dust fallback semantics

The fine-dust helper still broke the documented station-name path
when the station lookup API returned no rows, and it guessed a
KHAI label from PM10 whenever the upstream API omitted khaiGrade.
This change locks both behaviors with regressions, resolves station
selection to a direct measurement lookup when only the station name
is usable, and reports 정보없음 when the source omits the KHAI field.
The docs now match the implemented fallback and missing-field behavior.

Constraint: Keep the fix compatible with existing fetch_station_payload callers and PR #21 scope
Rejected: Recompute KHAI from other pollutant fields | official formula/data inputs are not implemented in this helper
Rejected: Fail hard when station lookup is empty despite --station-name | contradicts the documented direct station-name fallback
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not synthesize 통합대기등급 from PM10/PM2.5 surrogates unless the official KHAI formula and inputs are implemented end-to-end
Tested: python3 scripts/test_fine_dust.py; npm run ci; python3 scripts/fine_dust.py report --station-file scripts/fixtures/fine-dust-stations.json --measurement-file scripts/fixtures/fine-dust-measurements.json --lat 37.5665 --lon 126.9780; python3 scripts/fine_dust.py report --station-file scripts/fixtures/fine-dust-stations.json --measurement-file scripts/fixtures/fine-dust-measurements.json --region-hint '서울 강남구'; python3 scripts/fine_dust.py --help; env -u AIR_KOREA_OPEN_API_KEY python3 scripts/fine_dust.py report --lat 37.5665 --lon 126.9780; python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py
Not-tested: Live Air Korea API responses with a real service key
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-27 22:58:01 +09:00
commit 9ef274c3b7
4 changed files with 97 additions and 4 deletions

View file

@ -89,6 +89,7 @@ python3 scripts/fine_dust.py report \
- 위치 권한이 없으면 지역명/행정구역을 먼저 받습니다
- 지역명도 없으면 측정소명을 직접 받습니다
- 측정소 목록 API가 빈 응답이어도 `--station-name` 이 있으면 같은 이름으로 실시간 측정 API를 직접 재시도합니다
- `getNearbyMsrstnList` 결과가 비면 `getMsrstnList` 로 재시도합니다
- nearby 응답은 입력 TM 좌표와의 거리 기준으로 정렬되므로 첫 측정소를 우선 사용합니다
@ -96,4 +97,5 @@ python3 scripts/fine_dust.py report \
- 실시간 수치라 조회 시각을 같이 적어야 합니다
- PM10/PM2.5 값이 `-` 이거나 비정상이면 등급도 함께 재확인합니다
- API 가 `khaiGrade` 를 비워 보내면 통합대기등급은 `정보없음` 으로 표시합니다
- 위치 기반이라고 해도 실제 기준은 “가까운 측정소” 이므로 주소 중심점과 오차가 있을 수 있습니다

View file

@ -94,6 +94,8 @@ sops exec-env "$HOME/.config/k-skill/secrets.env" \
2. 지역명/행정구역 → `getMsrstnList`
3. 측정소명 직접 지정 → `getMsrstnAcctoRltmMesureDnsty`
`getMsrstnList` 가 빈 응답이어도 `--station-name` 이 있으면 helper 는 같은 이름으로 `getMsrstnAcctoRltmMesureDnsty` 를 직접 재시도한다.
### 4. Query the official real-time measurement API
선택한 가까운 측정소 이름으로 대기오염정보 API `getMsrstnAcctoRltmMesureDnsty` 를 호출해 PM10/PM2.5 와 등급을 가져온다.
@ -134,6 +136,7 @@ python3 scripts/fine_dust.py report \
- PM10 값과 등급
- PM2.5 값과 등급
- 좌표 기반 조회인지, 지역 fallback인지
- `khaiGrade` 가 비어 있으면 통합대기등급은 `정보없음`
## Done when

View file

@ -221,6 +221,29 @@ def pick_station(
return station_items[0]
def resolve_station(
station_items: list[dict],
*,
lat: float | None = None,
lon: float | None = None,
region_hint: str | None = None,
station_name: str | None = None,
) -> dict:
if station_items:
return pick_station(
station_items,
lat=lat,
lon=lon,
region_hint=region_hint,
station_name=station_name,
)
if station_name:
return {"stationName": station_name, "addr": None}
raise SystemExit("측정소 후보가 없습니다.")
def find_measurement(measurement_items: list[dict], station_name: str) -> dict:
exact_match = next((item for item in measurement_items if item.get("stationName") == station_name), None)
if exact_match:
@ -265,8 +288,9 @@ def build_report(
region_hint: str | None = None,
station_name: str | None = None,
lookup_mode: str | None = None,
selected_station: dict | None = None,
) -> dict:
station = pick_station(
station = selected_station or resolve_station(
station_items,
lat=lat,
lon=lon,
@ -298,7 +322,9 @@ def build_report(
value=measurement.get("pm25Value"),
),
},
"khai_grade": grade_to_label(
"khai_grade": "정보없음"
if measurement.get("khaiGrade") in (None, "")
else grade_to_label(
measurement.get("khaiGrade"),
pollutant="pm10",
value=measurement.get("pm10Value"),
@ -399,7 +425,7 @@ def render_text(report: dict) -> str:
return "\n".join(
[
f"측정소: {report['station_name']}",
f"주소: {report['station_address']}",
f"주소: {report['station_address'] or '-'}",
f"조회 시각: {report['measured_at']}",
f"조회 방식: {report['lookup_mode']}",
f"PM10: {report['pm10']['value']} ({report['pm10']['grade']})",
@ -412,7 +438,7 @@ def render_text(report: dict) -> str:
def command_report(args: argparse.Namespace) -> None:
station_payload, lookup_mode = fetch_station_lookup(args)
station_items = extract_items(station_payload)
station = pick_station(
station = resolve_station(
station_items,
lat=args.lat,
lon=args.lon,
@ -429,6 +455,7 @@ def command_report(args: argparse.Namespace) -> None:
region_hint=args.region_hint,
station_name=station["stationName"],
lookup_mode=lookup_mode,
selected_station=station,
)
if args.json:

View file

@ -69,6 +69,25 @@ class FineDustTests(unittest.TestCase):
self.assertEqual(report["pm25"], {"value": "19", "grade": "보통"})
self.assertEqual(report["measured_at"], "2026-03-27 21:00")
def test_build_report_marks_khai_grade_unknown_when_api_omits_it(self):
report = fine_dust.build_report(
station_items=[{"stationName": "중구", "addr": "서울 중구 서소문로 124"}],
measurement_items=[
{
"stationName": "중구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "42",
"pm10Grade": "2",
"pm25Value": "19",
"pm25Grade": "2",
"khaiGrade": None,
}
],
station_name="중구",
)
self.assertEqual(report["khai_grade"], "정보없음")
def test_cli_report_supports_fixture_inputs(self):
station_path = FIXTURES / "fine-dust-stations.json"
measurement_path = FIXTURES / "fine-dust-measurements.json"
@ -191,6 +210,48 @@ class FineDustTests(unittest.TestCase):
self.assertEqual(rendered["station_name"], "강남구")
self.assertEqual(rendered["lookup_mode"], "fallback")
def test_cli_json_report_uses_station_name_directly_when_station_lookup_is_empty(self):
stdout = io.StringIO()
recorded_calls = []
def fake_fetch_json(url, params):
recorded_calls.append((url, params))
if url.endswith("/getMsrstnList"):
return {"response": {"body": {"items": []}}}
if url.endswith("/getMsrstnAcctoRltmMesureDnsty"):
return {
"response": {
"body": {
"items": [
{
"stationName": "중구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "42",
"pm10Grade": "2",
"pm25Value": "19",
"pm25Grade": "2",
"khaiGrade": "2",
}
]
}
}
}
raise AssertionError(f"unexpected URL: {url}")
with (
redirect_stdout(stdout),
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
):
fine_dust.main(["report", "--station-name", "중구", "--json"])
rendered = json.loads(stdout.getvalue())
self.assertEqual(rendered["station_name"], "중구")
self.assertIsNone(rendered["station_address"])
self.assertEqual(rendered["lookup_mode"], "fallback")
self.assertEqual([url.rsplit("/", 1)[-1] for url, _ in recorded_calls], ["getMsrstnList", "getMsrstnAcctoRltmMesureDnsty"])
self.assertEqual(recorded_calls[1][1]["stationName"], "중구")
if __name__ == "__main__":
unittest.main()