# Saju Four Pillars Calculator — Korean BaZi Birth Chart (`kdatafactory/saju-engine`) Actor

Batch-computes Korean saju (사주) / BaZi four-pillar charts from birth timestamps, civil years 1900-2100 — a manseryeok engine, not a scraper. Each row carries the hour pillar under all seven conventions, plus 지장간, 십신 and 대운. Times that never existed or happened twice are flagged, not charted.

- **URL**: https://apify.com/kdatafactory/saju-engine.md
- **Developed by:** [Seok June Park](https://apify.com/kdatafactory) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-usage

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Saju Four Pillars Calculator — Korean BaZi Birth Chart 🗓️

**Turn a list of birth timestamps into four-pillar charts — 사주 / 四柱 / BaZi — in one batch run.**

This Actor **computes; it does not scrape.** There is no website behind it: it converts each
birth date + time into the sexagenary **year, month, day and hour pillars** using the sun's
apparent position (via the MIT-licensed `astronomy-engine` library) and the **IANA time-zone
database** that ships with the runtime. No network requests at run time, no API keys, no proxy.
Same input → same output, every time.

Two things it does differently, and they are the reason it exists:

1. **The hour pillar is returned under all seven supported conventions**, each labelled with what
   it did — clock time vs **mean local solar time** vs **true solar time**, day boundary at
   **00:00** vs **23:00**. Schools disagree about these choices (this is the 야자시 / 조자시
   discussion), so the Actor takes no position on which is right: you get all seven values plus
   the one you nominated as default. It will also show you that "apply true solar time" is not one
   instruction — mean and apparent solar time can land in different 지지 hours.
2. **Korean historical clocks are read from the tz database, not from a folk rule.** Korea ran at
   **UTC+08:30** from 1954-03-20 to 1961-08-10 (with **+09:30 summer time in 1955–1960**), and
   also from 1908-04-01 to 1911-12-31 — before that the clock was local mean time at
   **+08:27:52**. Local times that **never existed** or that **happened twice** are flagged instead
   of being charted silently — and most of those windows sit inside the 23:00–01:00 자시 hour.

> **Try it free.** The example input below is already pre-filled: open the Actor, click **Start**,
> and you get three rows — two charts and one row flagging a wall-clock time that never existed —
> for **$0.025** in total ($0.01 per run + $0.005 per billable row). Apify's free
> plan includes $5 of monthly platform credit and needs no credit card.

***

### 🧪 Example

**Input** — this is the pre-filled input (the remaining fields keep their defaults, omitted here):

```json
{
  "births": [
    { "id": "A-1990", "date": "1990-06-15", "time": "13:10", "sex": "male" },
    { "id": "B-1955", "date": "1955-07-10", "time": "23:40", "sex": "male" },
    { "id": "C-1961", "date": "1961-08-10", "time": "00:15", "sex": "female" }
  ],
  "defaultTimezone": "Asia/Seoul",
  "hourPillarConvention": "clock_midnight"
}
```

**Output** — one dataset row per input record. Record `B-1955` (a 23:40 birth during Korea's
UTC+09:30 summer time) is the one worth reading closely. This is the real row from the run above,
abridged only by dropping the per-convention `label_ko` / `label_en` prose, eight of the ten 대운
pillars, three of the four 지장간 groups, `undetermined_fields` and `warnings`. The complete,
unabridged three rows are in [`samples/sample-output.json`](samples/sample-output.json):

```json
{
  "input_index": 1,
  "id": "B-1955",
  "birth_date": "1955-07-10",
  "birth_time": "23:40",
  "sex": "male",
  "calendar": "gregorian",
  "timezone": "Asia/Seoul",
  "longitude_deg": 126.978,
  "record_status": "ok",
  "local_time_status": "unique",
  "local_time_note": null,
  "resolution_policy": null,
  "utc_instant": "1955-07-10T14:10:00.000Z",
  "alternate_utc_instant": null,
  "utc_offset": "+09:30",
  "utc_offset_minutes": 570,
  "offset_source": "iana-tzdb",
  "tzdb_version": "2026a",
  "ephemeris_version": "astronomy-engine 2.1.19",
  "engine_version": "saju-engine 1.0.0",
  "mean_local_time": "22:37",
  "mean_local_time_hms": "22:37:54",
  "true_solar_time": "22:32",
  "true_solar_time_hms": "22:32:46",
  "true_solar_correction_minutes": -67.23,
  "equation_of_time_minutes": -5.14,
  "solar_longitude_deg": 107.5443,
  "solar_year": 1955,
  "solar_term": "소서 小暑 (105°)",
  "solar_term_start": "1955-07-08T07:36 +09:30",
  "solar_term_start_utc": "1955-07-07T22:06:16.290Z",
  "next_solar_term": "입추 立秋 (135°)",
  "next_solar_term_start_utc": "1955-08-08T07:50:12.678Z",
  "year_boundary_ipchun": "1955-02-04T22:48 +08:30",
  "year_boundary_ipchun_utc": "1955-02-04T14:17:48.333Z",
  "year_boundary_ipchun_relation": "opened_solar_year",
  "solar_year_start_ipchun": "1955-02-04T22:48 +08:30",
  "solar_year_start_ipchun_utc": "1955-02-04T14:17:48.333Z",
  "solar_year_end_ipchun": "1956-02-05T04:43 +08:30",
  "solar_year_end_ipchun_utc": "1956-02-04T20:12:38.564Z",
  "minutes_to_nearest_term_boundary": -3843.73,
  "near_term_boundary": false,
  "known_ephemeris_discrepancy": null,
  "year_pillar": {
    "cjk": "乙未",
    "ko": "을미",
    "roman": "eul-mi",
    "stem": "乙",
    "branch": "未",
    "stem_element": "wood",
    "branch_element": "earth"
  },
  "month_pillar": {
    "cjk": "癸未",
    "ko": "계미",
    "roman": "gye-mi",
    "stem": "癸",
    "branch": "未",
    "stem_element": "water",
    "branch_element": "earth"
  },
  "day_pillar": {
    "cjk": "壬申",
    "ko": "임신",
    "roman": "im-sin",
    "stem": "壬",
    "branch": "申",
    "stem_element": "water",
    "branch_element": "metal"
  },
  "day_pillar_date": "1955-07-10",
  "day_pillar_rolled": false,
  "hour_pillar": {
    "cjk": "庚子",
    "ko": "경자",
    "roman": "gyeong-ja",
    "stem": "庚",
    "branch": "子",
    "stem_element": "metal",
    "branch_element": "water"
  },
  "hour_pillar_convention": "clock_midnight",
  "hour_pillar_by_convention": {
    "clock_midnight": {
      "time_basis": "23:40",
      "clock_date": "1955-07-10",
      "day_pillar_date": "1955-07-10",
      "rolled_to_next_day": false,
      "day_boundary": "00:00",
      "day_pillar": "壬申",
      "day_pillar_ko": "임신",
      "day_pillar_roman": "im-sin",
      "hour_pillar": "庚子",
      "hour_pillar_ko": "경자",
      "hour_pillar_roman": "gyeong-ja",
      "hour_branch": "子",
      "in_2300_half_of_jasi": true,
      "is_yajasi": true
    },
    "clock_23h": {
      "time_basis": "23:40",
      "clock_date": "1955-07-10",
      "day_pillar_date": "1955-07-11",
      "rolled_to_next_day": true,
      "day_boundary": "23:00",
      "day_pillar": "癸酉",
      "day_pillar_ko": "계유",
      "day_pillar_roman": "gye-yu",
      "hour_pillar": "壬子",
      "hour_pillar_ko": "임자",
      "hour_pillar_roman": "im-ja",
      "hour_branch": "子",
      "in_2300_half_of_jasi": true,
      "is_yajasi": false
    },
    "true_solar_midnight": {
      "time_basis": "22:32",
      "clock_date": "1955-07-10",
      "day_pillar_date": "1955-07-10",
      "rolled_to_next_day": false,
      "day_boundary": "00:00",
      "day_pillar": "壬申",
      "day_pillar_ko": "임신",
      "day_pillar_roman": "im-sin",
      "hour_pillar": "辛亥",
      "hour_pillar_ko": "신해",
      "hour_pillar_roman": "sin-hae",
      "hour_branch": "亥",
      "in_2300_half_of_jasi": false,
      "is_yajasi": false
    },
    "true_solar_23h": {
      "time_basis": "22:32",
      "clock_date": "1955-07-10",
      "day_pillar_date": "1955-07-10",
      "rolled_to_next_day": false,
      "day_boundary": "23:00",
      "day_pillar": "壬申",
      "day_pillar_ko": "임신",
      "day_pillar_roman": "im-sin",
      "hour_pillar": "辛亥",
      "hour_pillar_ko": "신해",
      "hour_pillar_roman": "sin-hae",
      "hour_branch": "亥",
      "in_2300_half_of_jasi": false,
      "is_yajasi": false
    },
    "lmt_midnight": {
      "time_basis": "22:37",
      "clock_date": "1955-07-10",
      "day_pillar_date": "1955-07-10",
      "rolled_to_next_day": false,
      "day_boundary": "00:00",
      "day_pillar": "壬申",
      "day_pillar_ko": "임신",
      "day_pillar_roman": "im-sin",
      "hour_pillar": "辛亥",
      "hour_pillar_ko": "신해",
      "hour_pillar_roman": "sin-hae",
      "hour_branch": "亥",
      "in_2300_half_of_jasi": false,
      "is_yajasi": false
    },
    "lmt_23h": {
      "time_basis": "22:37",
      "clock_date": "1955-07-10",
      "day_pillar_date": "1955-07-10",
      "rolled_to_next_day": false,
      "day_boundary": "23:00",
      "day_pillar": "壬申",
      "day_pillar_ko": "임신",
      "day_pillar_roman": "im-sin",
      "hour_pillar": "辛亥",
      "hour_pillar_ko": "신해",
      "hour_pillar_roman": "sin-hae",
      "hour_branch": "亥",
      "in_2300_half_of_jasi": false,
      "is_yajasi": false
    },
    "mixed_clock_day_true_solar_hour": {
      "time_basis": "22:32",
      "clock_date": "1955-07-10",
      "day_pillar_date": "1955-07-10",
      "rolled_to_next_day": false,
      "day_boundary": "00:00",
      "day_pillar": "壬申",
      "day_pillar_ko": "임신",
      "day_pillar_roman": "im-sin",
      "hour_pillar": "辛亥",
      "hour_pillar_ko": "신해",
      "hour_pillar_roman": "sin-hae",
      "hour_branch": "亥",
      "in_2300_half_of_jasi": false,
      "is_yajasi": false
    }
  },
  "day_master": "壬",
  "day_master_element": "water",
  "day_master_polarity": "yang",
  "element_counts": {
    "wood": 1,
    "fire": 0,
    "earth": 2,
    "metal": 2,
    "water": 3
  },
  "element_counts_with_hidden_stems": {
    "wood": 3,
    "fire": 2,
    "earth": 5,
    "metal": 3,
    "water": 6
  },
  "element_counts_note": "element_counts counts the eight characters themselves (8 with an hour pillar, 6 without). element_counts_with_hidden_stems adds every 지장간 of every present branch, UNWEIGHTED — one count per hidden stem regardless of its 월령용사 day split. A branch with a 중기 therefore contributes 3 hidden stems and 자/묘/유 contribute 2, so the total varies (16-20 with an hour pillar, 12-15 without) and the relative 오행 strengths are distorted by the branch composition rather than by the chart. It is a CHARACTER COUNT, not 오행 강약, which is school-specific interpretation and out of scope. To weight it yourself, use the `days` figure on every stem in the `hidden_stems` field of this same row.",
  "ten_gods": {
    "year_stem": "상관",
    "month_stem": "겁재",
    "hour_stem": "편인",
    "year_branch": "정관",
    "month_branch": "정관",
    "day_branch": "편인",
    "hour_branch": "겁재"
  },
  "ten_gods_branch_basis": "jeonggi",
  "ten_gods_branch_alternate": {
    "year_branch": "정관",
    "month_branch": "정관",
    "day_branch": "편인",
    "hour_branch": "비견"
  },
  "ten_gods_branch_alternate_basis": "positional",
  "hidden_stems": {
    "day": [
      {
        "role": "여기",
        "stem": "戊",
        "stem_ko": "무",
        "element": "earth",
        "polarity": "yang",
        "days": 7
      },
      {
        "role": "중기",
        "stem": "壬",
        "stem_ko": "임",
        "element": "water",
        "polarity": "yang",
        "days": 7
      },
      {
        "role": "정기",
        "stem": "庚",
        "stem_ko": "경",
        "element": "metal",
        "polarity": "yang",
        "days": 16
      }
    ]
  },
  "hidden_stems_table_note": "지장간 composition is agreed across sources; two cells are school-dependent and this table chooses the majority reading: 申 여기 = 戊 (some tables use 己, which is what the 여기 = previous branch's 정기 pattern would predict), and 午 day splits = 10/9/11 (some tables use 10/10/10). Day splits are 월령용사 figures, applied to the year, day and hour branches by Korean convention rather than by doctrine.",
  "daeun": {
    "direction": "reverse",
    "direction_ko": "역행",
    "direction_determined": true,
    "direction_alternative": null,
    "governing_term": "소서 小暑 (105°)",
    "reference_term": "소서 小暑 (105°)",
    "reference_term_utc": "1955-07-07T22:06:16.290Z",
    "distance_days": 2.66926,
    "start_age_exact_years": 0.88975,
    "start_age_years_min": 0.88975,
    "start_age_years_max": 0.88975,
    "start_age_daeunsu": 1,
    "distance_days_wholeday": 2,
    "start_age_daeunsu_wholeday": 1,
    "start_age_daeunsu_rounded": 1,
    "start_age_daeunsu_truncated": 1,
    "start_age_daeunsu_minus_exact_years": 0.11025,
    "start_age_daeunsu_floored_to_one": false,
    "rule": "3 days = 1 year (1 day = 4 months, 1 hour = 5 days); 대운수 = floor((distance_days + 1) / 3), the classical \"discard 1 day, round up 2 days\"",
    "pillars": [
      {
        "ordinal": 1,
        "cjk": "壬午",
        "ko": "임오",
        "roman": "im-o",
        "stem": "壬",
        "branch": "午",
        "start_age_exact_years": 0.88975,
        "start_age_daeunsu": 1
      },
      {
        "ordinal": 2,
        "cjk": "辛巳",
        "ko": "신사",
        "roman": "sin-sa",
        "stem": "辛",
        "branch": "巳",
        "start_age_exact_years": 10.88975,
        "start_age_daeunsu": 11
      }
    ]
  }
}
```

Three things this one row shows:

- **The hour pillar is not one number.** 庚子 / 壬子 / 辛亥 depending on the convention — and under
  the 23:00 day boundary the **day pillar itself** moves from 壬申 to 癸酉. A chart that quotes a
  single hour pillar has silently picked one of these for you. `in_2300_half_of_jasi` marks the two
  conventions on whose own clock this birth sits in the 23:00–23:59 half of 자시 — the plain fact,
  true regardless of school. `is_yajasi` is narrower on purpose: it is the doctrinal 야자시 label, so
  it is true only on the 00:00-boundary conventions, since a convention whose day already starts at
  23:00 treats that hour as the OPENING 자시 of the next day (`rolled_to_next_day`) and has no
  야자시 category at all.
- **The "subtract 32 minutes" rule does not apply to this birth.** The clock was at **+09:30**
  that night, so the true-solar correction is **−67.23 minutes**, not −32. (For a modern
  +09:00 birth the same field reads about −32 minutes, as in record `A-1990`.)
- **Nothing is hard-coded.** `utc_offset` came from the tz database (`offset_source`,
  `tzdb_version` tell you which), `solar_longitude_deg`, `solar_term_start` and
  `year_boundary_ipchun` from the sun's apparent position, and 대운 from the measured distance to
  the governing 절기 — which is why `distance_days` is a fraction and not a whole number.

And record `C-1961` is the case most calculators answer anyway — the real row, showing the fields
that carry the answer (of its 66 columns the rest are echoes of your input, the time audit, and
`null`):

```json
{
  "input_index": 2,
  "id": "C-1961",
  "birth_date": "1961-08-10",
  "birth_time": "00:15",
  "record_status": "nonexistent_local_time",
  "local_time_status": "nonexistent",
  "local_time_note": "1961-08-10 00:15 never existed in Asia/Seoul: the clock jumped forward from 1961-08-09 23:59 (+08:30) to 1961-08-10 00:30 (+09:00).",
  "resolution_policy": "flag",
  "utc_instant": null,
  "alternate_utc_instant": null,
  "year_pillar": null,
  "month_pillar": null,
  "day_pillar": null,
  "hour_pillar": null,
  "hour_pillar_by_convention": null,
  "daeun": null,
  "warnings": [
    "nonexistent_local_time: this wall-clock time never existed in this zone, so no chart is returned. Set nonexistentTimePolicy to \"shift_forward\" if your pipeline needs a chart anyway."
  ]
}
```

Both readings of that wall-clock time (23:45 at +08:30, or 00:45 at +09:00) fall on **different
days**, so they give different day pillars. With the default policy the Actor returns the row with
the pillars `null` and says why; set `nonexistentTimePolicy` to `shift_forward` if your pipeline
needs a number anyway (it is then marked in `resolution_policy` and `warnings`).

The full three-row sample lives in [`samples/sample-output.json`](samples/sample-output.json).

***

### 🚀 What it computes

| Output | How it is derived |
|--------|-------------------|
| **Year pillar** 년주 | Solar year, boundary at **입춘 立春** (sun's apparent longitude 315°) — not 1 January and not lunar new year. The 입춘 fields say which instant is which, because one plays two roles: `year_boundary_ipchun` (+ `_utc`) is the 입춘 of the **civil** year in your `date`, which for a January or pre-입춘 February birth is the one that **closes** the reported `solar_year` rather than the one that opened it, and `year_boundary_ipchun_relation` names that role (`opened_solar_year` / `closes_solar_year`); `solar_year_start_ipchun` and `solar_year_end_ipchun` (+ `_utc`) always bracket the reported `solar_year` itself, so the interval the year pillar belongs to is in the row. |
| **Month pillar** 월주 | Month **branch** from the 30° solar-longitude sector the birth falls in (the twelve 절기 sectors); month **stem** from the year stem. `solar_term` and `solar_term_start` name the sector. |
| **Day pillar** 일주 | Continuous sexagenary day count, under the day boundary of each convention (00:00 or 23:00). |
| **Hour pillar** 시주 | Branch from the two-hour 지지 slot, stem from the day stem — computed under **all seven conventions**, plus the one you nominated. |
| **Day master** 일간 | The day-pillar stem, with its element and yin/yang polarity. |
| **Element counts** 오행 분포 | How many of the eight characters fall in each of wood / fire / earth / metal / water. Returned twice: over the eight characters alone (sums to 8), and again including every 지장간 stem. The second is **unweighted**, so its total varies with the branches present — `element_counts_note` says so in every row, because a character count is not 오행 강약. |
| **Hidden stems** 지장간 | The 여기 / 중기 / 정기 stems of all four branches with their 월령용사 day splits, each with its element. Table lookup only, no interpretation. The two cells that schools genuinely disagree on are named in `hidden_stems_table_note` rather than quietly chosen. |
| **Ten gods** 십신 | The 십신 of the other seven characters relative to the 일간. A 지지 has no stem of its own, so this needs a second convention — and the two defensible readings differ for exactly 子·午·巳·亥. Both are returned: `ten_gods` on your chosen basis, `ten_gods_branch_alternate` on the other, with `ten_gods_branch_basis` and `ten_gods_branch_alternate_basis` naming which is which. |
| **Luck pillars** 대운 | Direction by 양남음녀 from the 입춘-based year stem, distance to the governing 절기 measured as pure instant arithmetic, and the 3-days-to-1-year rule stated exactly — the fractional `start_age_exact_years`, the **classical** 대운수 in `start_age_daeunsu`, and the two alternative roundings other engines use (`start_age_daeunsu_rounded`, `_truncated`), none of them silently preferred. Needs the record's `sex`. |
| **Time audit** | `utc_instant`, `utc_offset`, `utc_offset_minutes`, `offset_source`, `tzdb_version`, `ephemeris_version`, `engine_version`, `mean_local_time`, `true_solar_time`, `true_solar_correction_minutes`, `equation_of_time_minutes`, `solar_longitude_deg`, `minutes_to_nearest_term_boundary`, `local_time_status`. |

Each pillar comes with hanja (`cjk`), Korean (`ko`) and romanised (`roman`) forms, so you do not
have to map 60 stem-branch pairs yourself.

#### The seven hour-pillar conventions

| Key | Time basis | Day boundary |
|-----|-----------|--------------|
| `clock_midnight` | the local clock time as recorded | 00:00 |
| `clock_23h` | the local clock time as recorded | 23:00 |
| `true_solar_midnight` | true (apparent) solar time — longitude **+ equation of time** | 00:00 |
| `true_solar_23h` | true (apparent) solar time | 23:00 |
| `lmt_midnight` | mean local solar time — **longitude only** | 00:00 |
| `lmt_23h` | mean local solar time | 23:00 |
| `mixed_clock_day_true_solar_hour` | day pillar from the **civil** date, hour branch from **true solar** time | 00:00 |

All seven are in **every** chartable row regardless of your default. `hourPillarConvention` only
decides which one is copied into the top-level `hour_pillar`, `day_pillar`, `element_counts`,
`ten_gods` and `hidden_stems` fields. **This Actor does not rank the conventions or claim one is
the true one, and the default (`clock_midnight`) is a default, not a verdict.**

Three notes that matter when you compare this output to another engine:

- **The `lmt_*` pair is the Korean "subtract about 32 minutes" folk rule, done properly.** Mean
  local time is computed as **UTC + longitude ÷ 15 hours** from the birth's own instant and the
  longitude on the record — never from an assumed 135°E meridian — so it is also right inside the
  UTC+08:30 era, where the folk rule is a full half-hour out: a Seoul birth then reads about **2
  minutes** off its wall clock, not 32.
- **Mean and apparent solar time disagree with each other.** For a 1990-05-15 13:30 KST birth they
  are 12:57 and 13:01, which straddles the 午/未 boundary at 13:00 and yields 壬午 under
  `lmt_midnight` but 癸未 under `true_solar_midnight`. "Apply true solar time" is not one
  instruction.
- **`mixed_clock_day_true_solar_hour` is deliberately incoherent** and its `label_en` says so. A
  solar clock can fall on a different calendar day from the civil one (civil 2000-01-03 00:20 KST
  is apparent solar 2000-01-02 23:44), so taking the day from one clock and the hour from another
  is not any school's rule. It is included only so results can be reconciled against popular
  calculators that combine them that way.

#### The 십신 branch basis, and the 지장간 cells that are contested

A 지지 carries no stem, so reading its 십신 requires a choice, and the two defensible answers
disagree for exactly **子 · 午 · 巳 · 亥** — the four 體用 inversions where a branch's positional
polarity is the opposite of its 정기 지장간's. `tenGodsBranchBasis` nominates one; **both are
always returned**, on the same labelled-convention principle as the hour pillar.

Two 지장간 cells are likewise school-dependent, and this Actor states its choice in
`hidden_stems_table_note` on every row instead of letting it harden into an apparent fact: **申
여기** is 戊 here where some tables use 己, and **午**'s day splits are 10/9/11 here where some
tables use 10/10/10.

***

### ⏱️ Why the time handling is the hard part

Korean birth times are a minefield that has nothing to do with astrology:

- **Korea changed its standard offset, four times inside the supported range.** In `Asia/Seoul`:
  **local mean time +08:27:52** until 1908-03-31, **UTC+08:30** from 1908-04-01 to 1911-12-31,
  **UTC+09:00** from 1912-01-01, **UTC+08:30** again from 1954-03-20 to 1961-08-10, and **UTC+09:00**
  since — plus **summer time** in 1948–1951 (+10:00), **1955–1960 (+09:30)** and 1987–1988
  (+10:00). Every one of those eras holds births this Actor will chart. It takes the offset for the
  exact birth minute from the tz database and reports it — so the popular "Korea is 30-ish minutes
  off the 135°E meridian, subtract 32 minutes" shortcut is not used, and is off by half an hour
  for anyone born in the +08:30 / +09:30 years and by a ragged 32 seconds before 1908.
- **Some local times never existed, and some happened twice.** Every forward jump erases a window
  of wall-clock time and every backward jump repeats one. `Asia/Seoul` carries **28 offset
  changes**, which leave **15 local-time windows that never existed** — 14 of them since 1912,
  plus a two-minute one in 1908 when Korea left local mean time — and **13 windows that happened
  twice** (enumerated identically on IANA tzdb 2026a and 2026c). Many of them land in the
  **23:00–01:00** range, the 자시 hour this product is about: 1961-08-10 00:00–00:29 is gone,
  1954-03-20 23:30–23:59 happened twice.
- **So the Actor classifies every input minute** as `unique`, `nonexistent`, `repeated` or
  `time_unknown`, and lets you choose the policy for the awkward ones
  (`nonexistentTimePolicy`, `ambiguousTimePolicy`). Nothing is inferred behind your back:
  whatever it did is in `resolution_policy`, `local_time_note` and `warnings`.
- **Non-Korean births work too.** Any IANA zone id is accepted per record (`timezone`), with the
  longitude used for true solar time settable per record (`longitude`).

***

### 📥 Input

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `births` | array of objects | 3 example records | The batch. Each record: `date` (`YYYY-MM-DD`, required, Gregorian), `time` (`HH:MM` or `HH:MM:SS`, 24-hour; omit if unknown), `sex` (`male` / `female`, needed only for 대운), `id` (your own key, echoed back), `timezone` (IANA id), `longitude` (decimal degrees east — **required when `timezone` moves the record out of `defaultTimezone`**, because `defaultLongitude` then belongs to a different place; such a record comes back `longitude_required` rather than being charted on Seoul's meridian), `calendar` (must be `gregorian` if given — a record marked `lunar` is rejected, never guessed at). **Civil birth years 1900–2100** — the year in `date`, not the solar year; see Known limitations for what that means at each end. Outside it the row comes back `out_of_range` and is not billed. Up to **5,000** records per run. |
| `hourPillarConvention` | enum | `clock_midnight` | Which convention fills the top-level `hour_pillar` / `day_pillar` / `element_counts` / `ten_gods` / `hidden_stems`. All seven are always returned. |
| `tenGodsBranchBasis` | enum | `jeonggi` | Which branch reading fills `ten_gods`; the other fills `ten_gods_branch_alternate`. Both are always returned. |
| `daeunCount` | integer | `10` | How many ten-year 대운 pillars to list (1–12). |
| `defaultTimezone` | string | `Asia/Seoul` | IANA zone used for records that omit `timezone`. |
| `defaultLongitude` | number | `126.978` | Longitude (°E) for the solar-time conventions when a record omits `longitude`. 126.978 is Seoul; solar time does not depend on latitude. |
| `nonexistentTimePolicy` | enum | `flag` | `flag` = return the row with null pillars and a note; `shift_forward` = chart the first local time that did exist, marked in `warnings`. |
| `ambiguousTimePolicy` | enum | `flag` | For wall-clock times that happened twice: `flag` (both instants are reported), or pick the `earlier` / `later` instant, marked in `warnings`. |
| `boundaryFlagMinutes` | integer | `10` | How close to a 절기 instant a birth must be to set `near_term_boundary`. A product choice, **not** a measured ephemeris error bound — see Known limitations. |

**Records with no birth time** are first-class, not a degraded path: `time` is omitted,
`local_time_status` is `time_unknown`, the year / month / day pillars are computed under the 00:00
day boundary, and `hour_pillar` and `hour_pillar_by_convention` are **`null`** — never defaulted to
자시 or to noon. `element_counts` then sums to 6 rather than 8, and 대운 comes back as a
`start_age_years_min` / `_max` bracket computed at local 00:00 and 23:59:59 with
`start_age_exact_years` null. A 23:00–24:00 birth would move the day pillar under the 23:00
conventions, so that caveat is stated in `warnings` rather than guessed at.

**One input record = exactly one dataset row**, in input order, with `input_index` and your `id`
on it — so a 500-name batch is a 500-row dataset you can join straight back to your source table.
That holds for flagged, out-of-range and rejected records too: they come back as rows with
`record_status` and a reason in `warnings`, not as gaps in your join. **Rejected rows are not
billed** — see [What you are billed for](#-what-you-are-billed-for). A run in which **no row at
all** could be charted or flagged is marked failed **and charged nothing**, so a completely bad
input never looks like a success and never appears on the invoice.

***

### 📤 Output fields

| Field | Type | Notes |
|-------|------|-------|
| `input_index`, `id` | integer, string | null | Your record's position and key. |
| `birth_date`, `birth_time`, `sex`, `calendar`, `timezone`, `longitude_deg` | string | null … | Echo of the resolved input. `calendar` is always `gregorian`. |
| `record_status` | string | `ok` | `nonexistent_local_time` | `ambiguous_local_time` | `invalid_input` | `out_of_range` | `longitude_required`. The one field that is never null — read this first. |
| `local_time_status` | string | null | `unique` | `nonexistent` | `repeated` | `time_unknown`. |
| `local_time_note`, `resolution_policy`, `warnings` | string | null, string | null, array | What was ambiguous or wrong, and what the Actor did about it. `warnings` is `[]` when clean and is never null. |
| `undetermined_fields` | array | Names the fields in THIS row that the input did not pin down, so you can filter on them instead of reading prose: a birth date with no time, on a day that carries a 節 boundary, lists `solar_year`, `year_pillar`, `month_pillar`, the 절기 fields, `element_counts`, `ten_gods`, `hidden_stems` and the affected `daeun.*` keys — because the ±12 h uncertainty spans that boundary and the values shown are the local-noon reading. `[]` when nothing is undetermined, and never null. |
| `utc_instant`, `alternate_utc_instant`, `utc_offset`, `utc_offset_minutes`, `offset_source`, `tzdb_version` | string | null, number | null | The instant the chart was computed from, the other occurrence when a wall clock happened twice, and where the offset came from. `utc_offset_minutes` is a **number, not an integer**: pre-1908 Seoul was local mean time at +08:27:52, i.e. 507.86… minutes. |
| `ephemeris_version`, `engine_version` | string | Which ephemeris library and which algorithm version produced the row. These plus `tzdb_version` are the provenance — there is deliberately **no timestamp field**, because one would break the determinism the rest of this page promises. |
| `mean_local_time`, `true_solar_time` (+ `_hms`), `true_solar_correction_minutes`, `equation_of_time_minutes` | string | null, number | null | Mean and apparent local solar time and the two components of the correction. |
| `solar_longitude_deg`, `solar_year`, `solar_term`, `solar_term_start` (+ `_utc`), `next_solar_term` (+ `_start_utc`) | number | null, integer | null, string | null | The astronomy the month and year pillars stand on. `solar_year` is the 입춘-based year, which is not always the calendar year. |
| `year_boundary_ipchun` (+ `_utc`), `year_boundary_ipchun_relation`, `solar_year_start_ipchun` (+ `_utc`), `solar_year_end_ipchun` (+ `_utc`) | string | null | The 입춘 fields, and they are not interchangeable. `year_boundary_ipchun` is the 입춘 of the birth's **civil** year — the comparison the engine actually made — which for a January or pre-입춘 February birth is the one that **closes** the reported `solar_year` rather than the one that opened it, and `year_boundary_ipchun_relation` says which (`opened_solar_year` / `closes_solar_year`). `solar_year_start_ipchun` and `solar_year_end_ipchun` always bracket the reported `solar_year` itself: the year pillar in the row is the pillar of exactly that half-open interval, and `utc_instant` always falls inside it. |
| `minutes_to_nearest_term_boundary`, `near_term_boundary` | number | null, boolean | null | Signed distance to the nearer 절기 instant (negative = the term began that many minutes ago) and whether it is inside `boundaryFlagMinutes`. |
| `known_ephemeris_discrepancy` | object | null | Non-null only when a 節 that governs or follows this birth is a recorded case where this engine's instant and a published national table fall on **different Korean calendar days** — there is exactly one in 1900–2100 (1964 백로; see Known limitations). It names the term, both instants, the gap in seconds, the authority, the effect, and `row_affected`, which is `true` only for the rows the disagreement can actually change. Those rows also carry a warning. |
| `year_pillar`, `month_pillar`, `day_pillar`, `hour_pillar` | object | null | `{ cjk, ko, roman, stem, branch, stem_element, branch_element }`. |
| `hour_pillar_convention`, `hour_pillar_by_convention` | string | null, object | null | Your default, and all seven conventions keyed by name — each with `time_basis`, `clock_date`, `day_pillar_date`, `rolled_to_next_day`, `day_boundary`, the day and hour pillars in hanja / Korean / romanised form, `hour_branch`, `in_2300_half_of_jasi`, `is_yajasi`, and a Korean and English label. |
| `day_pillar_date`, `day_pillar_rolled` | string | null, boolean | null | The calendar date the day pillar was taken from under your chosen convention, and whether that is the **next** day — which happens for a 23:00–23:59 birth read under a 23:00 day boundary. So a row whose `day_pillar` does not belong to its `birth_date` says so on its own line, not only by the two dates differing. Null when no chart was produced. |
| `day_master`, `day_master_element`, `day_master_polarity` | string | null | Day-pillar stem, its element, `yang` / `yin`. |
| `element_counts`, `element_counts_with_hidden_stems`, `element_counts_note` | object | null, string | null | `{ wood, fire, earth, metal, water }` over the eight characters, and again including every 지장간 stem. The second one is **unweighted**, so its total moves with the branch composition (16–20 with an hour pillar, 12–15 without) — it is a character count, not 오행 강약. `element_counts_note` says that in the row itself and points at the `days` figures in `hidden_stems` for weighting it yourself. |
| `ten_gods`, `ten_gods_branch_basis`, `ten_gods_branch_alternate`, `ten_gods_branch_alternate_basis` | object | null, string | null | The seven 십신 on your chosen branch basis, which basis that was, the four branch values on the other basis, and — so the alternate is never four 십신 with no stated reading behind them — which basis produced those. |
| `hidden_stems`, `hidden_stems_table_note` | object | null, string | null | 지장간 per pillar as `{ role, stem, stem_ko, element, polarity, days }`, and the note naming the two contested cells. |
| `daeun` | object | null | Direction (with `direction_determined` / `direction_alternative`), the reference 절기, `distance_days` and `distance_days_wholeday`, `start_age_exact_years`, the min/max bracket, the classical `start_age_daeunsu` with its hand-checkable `_wholeday` restatement and the `_rounded` / `_truncated` alternatives, `start_age_daeunsu_minus_exact_years` and `start_age_daeunsu_floored_to_one` for the gap between integer and fraction, the rule in words, and the pillar list. Null when `sex` is absent, with a warning saying so. |

Fields are `null` (never missing) when a record could not be charted, so the **top-level** column
set is the same on every row — charted, flagged, rejected or out of range — and a CSV/Excel export
of those columns is stable. The nested objects are a different matter: `hidden_stems` drops the
지장간 roles a branch does not have (자·묘·유 have two, the rest three),
`hour_pillar_by_convention` is a seven-key object on charted rows and `null` on flagged and
`time_unknown` rows, and `daeun` and the pillar objects are likewise null-or-object. So an export
that **flattens** nested objects into their own columns produces a header set that depends on which
rows are in the batch; select the top-level columns if you need a fixed header. Which columns may be
null is not decided by hand: `tools/regen-dataset-schema.mjs` charts a probe battery, observes every
field the engine can leave empty, and refuses to write a dataset schema whose declarations do not
cover what was actually emitted — because a single undeclared null fails an entire Apify run.

**Time formatting rule, applied everywhere:** human-readable `HH:MM` and display strings are
**truncated** to the minute, the way a clock reads, and every one of them has a full-precision
sibling (`*_hms`, or a `*_utc` field carrying full ISO-8601 with milliseconds). Nothing is rounded
up, so a 22:32:46 solar time never appears as 22:33.

***

### 💵 What you are billed for

Two pay-per-event charges, and nothing else:

| Charge | Price | When |
|--------|-------|------|
| **Run** | **$0.01** | Once per run that produces at least one billable row. |
| **Chart** | **$0.005** | Once per **billable row** — $5.00 per 1,000 charts. |

A row is **billable** when its `record_status` is:

- `ok` — a chart was computed;
- `nonexistent_local_time` or `ambiguous_local_time` — the wall clock you gave never existed, or
  happened twice. These bill. The row is the answer: it names the clock change, carries both
  candidate instants where there are two, and tells you the day pillar is genuinely undetermined
  rather than picking one. That is the analysis you came here for.

A row is **not billable**, and is pushed free of charge, when its `record_status` is:

- `invalid_input` — a malformed date, or a record marked `calendar: "lunar"`;
- `out_of_range` — a **civil** birth year (the year in `date`) outside 1900–2100;
- `longitude_required` — the record moved the time zone but gave no `longitude`, so the meridian the
  four solar-clock conventions need is unknown (see Input).

No chart is computed for those, so you are not charged for the Actor refusing your own data — but
the rows are still in the dataset, one per input record, with the reason in `warnings`, so your join
keeps its shape.

**A run that produces no billable row at all costs nothing.** Its rows are pushed for free and the
run is marked failed. Sending a single record with a nonexistent birth time, on the other hand, is a
**successful** run that bills $0.015 — flagging that minute is the product working.

**Reaching your run's charge cap does not truncate the dataset.** Rows are pushed without a
per-dataset-item charge, so when the cap is reached the Actor stops charging and keeps pushing: the
run log says how many rows you received uncharged. The estimate is also printed up front, from the
prices the platform reports for that run.

***

### 💡 Use cases

- **Bulk-charting an existing list** — a members table, a survey export, a research cohort:
  hand the Actor the list you already hold and join the pillars back by `id`.
- **Powering your own saju app or bot** — use this as the calculation layer and keep your own
  interpretation, wording and design. The Actor returns structures, never prose.
- **Auditing charts you already have** — the per-convention breakdown plus the time audit fields
  show exactly which assumptions a number came from, which is what disagreements usually turn on.
- **Research and data cleaning** — `local_time_status` will tell you how many rows of a Korean
  birth-record dataset carry a wall-clock time that never existed.
- **AI agents** — a deterministic, structured tool call for an agent that reasons about
  four-pillar data instead of inventing it (see the MCP section below).

***

### 🔒 Privacy & data handling

- **Your birth records are not retained by this Actor.** It writes nothing outside the run,
  makes **no network requests at run time**, and sends nothing anywhere: the input and the
  resulting dataset live in **your own Apify account's storage**, under your retention settings,
  and you can delete them whenever you like. The maintainer never sees them.
- **Nothing is fetched about a person.** The Actor receives only the timestamp you send and
  computes from it — it does not look anyone up.
- **No personal identifiers are required.** `id` is whatever key you choose; pass an opaque
  reference instead of a name if you prefer.
- **No cookies, no credentials, no proxy, no third-party service** is involved.

***

### ⚠️ Known limitations (honest notes)

- **Gregorian (양력) input only.** Give it the solar calendar date. Lunar (음력) birthdates must be
  converted to 양력 before you send them — the Actor does not ship a lunar-calendar conversion,
  and it will not guess.
- **It computes, it does not interpret.** No reading text, no personality typing, no
  compatibility (궁합) scoring, no 세운 / 월운, no 신살 or 십이운성 tables. 지장간, 십신 and 대운 are
  table lookups and arithmetic, not readings. You get the characters, their elements and a full
  audit trail of how they were derived — the meaning is yours to write.
- **The conventions are exposed, not adjudicated.** The Actor supports seven hour-pillar
  conventions, two branch 십신 bases, and the classical 대운수 beside both alternative roundings,
  and reports all of them; it does not tell you which school is right, and the default
  (`clock_midnight`) is a neutral baseline, not a verdict.
- **The month boundary is the true 절기 instant.** A birth within minutes of a solar-term
  crossing changes month pillar — correct behaviour, but it means a chart can differ from a
  printed 만세력 that rounds a term to a whole day.
- **Solar-term instants: sub-minute against national tables in the modern era, up to two minutes
  out at 2100 — and the reason at 2100 is ΔT, not the ephemeris.** The 절기 instants come from
  `astronomy-engine`'s truncated VSOP87 series, but that truncation is not what limits them.
  Turning the sun's position into a *clock* time needs **ΔT**, the gap between Terrestrial Time and
  the Earth's actual rotation — and ΔT decades ahead is a **prediction**, not a computation. Term by
  term, in the two independent reviews of 2026-09-29, this engine was compared against the National
  Astronomical Observatory of Japan (暦要項 2025–2026: 24 terms, worst 59 s; 長期版 for 1912, 1961,
  1990 and 2050: 48 terms, worst 48 s) and the Hong Kong Observatory (2026–2028: 36 terms, worst
  59 s). Measured deviations, this engine minus the observatory (negative = this engine is early):
  **+0 s at 1912, +10 s at 1961, +14 s at 1990, −14 s at 2050 — and −110 to −142 s at 2100.** That
  last band is not a defect a better ephemeris would remove: NAOJ's
  long-term table assumes ΔT ≈ 102 s in 2100 where `astronomy-engine`'s Espenak–Meeus extrapolation
  gives ≈ 203 s (at 2050 it is ≈ 93 s against NAOJ's 73 s), and **which of the two is right cannot
  be known today** — it depends on how the Earth actually rotates between now and then. So treat a
  late-century term instant as **± 2 minutes**, and a 2090s birth within a few minutes of a 節 as
  genuinely undetermined rather than as a number one of us has got wrong. Two things stay true at
  every date: the 10-minute `boundaryFlagMinutes` window is a **product choice, not a measured
  error bound**, and every row carries the signed `minutes_to_nearest_term_boundary` so you can
  apply your own threshold. Still **not** compared: KASI's (한국천문연구원) own published 절기
  tables, which are the reference a Korean buyer reaches for.
- **One 節 in the whole range falls on a different Korean calendar day from the national tables:
  1964 백로.** This engine puts it at **1964-09-08 00:00:00.018 KST**; NAOJ's 長期版 gives
  **1964-09-07 23:59**, about a minute earlier and on the previous day. The round-2 review scanned
  all 2,412 節 of 1900–2100 for instants within five minutes of local midnight — 13 of them — and
  checked every one against NAOJ: this is the **only** date flip in the whole range. A birth in the
  last minute of 1964-09-07 is therefore charted here as 입추 with month pillar 壬申 where a
  NAOJ-grade table gives 백로 and 癸酉. The row does not hide it. Any row whose governing or next 節
  is that 백로 carries a `known_ephemeris_discrepancy` object — the term, both instants, the gap in
  seconds, the authority, and the exact window affected — and `row_affected` is `true` for the rows
  the disagreement can actually change, which also get a warning telling you not to use that month
  pillar without a second source. `near_term_boundary` is `true` there too, and
  `minutes_to_nearest_term_boundary` reads 0.5 for a 23:59:30 birth. But the month pillar printed is
  the one this ephemeris implies.
- **Supported birth years are 1900–2100 by CIVIL year** — the year in the `date` you send, not the
  사주 solar year. That is what the input schema states and what the code tests: outside it the row
  comes back `out_of_range` with null pillars, and is not billed. The distinction is visible at both
  ends. At the bottom it gives you **more** than solar year 1900: a 1900-01-10 birth is charted and
  reports `solar_year` **1899**, because 입춘 1900 only arrives on 1900-02-04. At the top it gives
  you **less** than solar year 2100: the last chartable date is 2100-12-31, still inside 子月
  (대설), and the **丑月 of solar year 2100 is not chartable at all** — it opens with 소한 on
  **2101-01-05** and runs to 입춘 on 2101-02-04, and those births are civil 2101, so they come back
  `out_of_range`. Solar year 2100 is covered up to its 子月 and no further. The engine itself works
  wider, so the window is a product choice about how far the time data deserves to be sold, not a
  computational limit. Note what the floor does **not** mean: Korean births from 1900-01-01 to
  1908-03-31 **are** charted, on the tz database's nominal **local mean time of +08:27:52** — the
  first legislated Korean offset is +08:30 on 1908-04-01. Every row reports the offset it used and
  where it came from (`utc_offset`, `offset_source`), so a chart on nominal LMT is visible as such
  rather than implied.
- **The 대운수 integer depends on the rounding, and which one you compare against decides whether
  you "disagree".** `start_age_daeunsu` carries the classical rule — `floor((distance_days + 1) / 3)`,
  "discard a remainder of one day, count a remainder of two days as a year" — and it is the field to
  line up against a printed 만세력. Because the distance here is **fractional** instant arithmetic
  while a practitioner counts whole days off a table, the row also carries `distance_days_wholeday`
  (the whole completed days to the reference 節) and `start_age_daeunsu_wholeday` (that count put
  through the same rule), so the number can be re-derived by hand from the row; those two never
  disagree with `start_age_daeunsu`. What does disagree is the rounding: `start_age_daeunsu_rounded`
  (`round`) and `start_age_daeunsu_truncated` (`floor`) are what other engines use, emitted so a
  difference can be traced instead of argued about. It is not cosmetic — on 6,000 random known-time
  births (1930–2019, random date, time and sex) the rounded value differs from the classical one by
  a **full year in 14.8 %** of rows and the truncated value in **29.7 %**, and the independent
  round-2 review measured **13.9 %** (472 of ~3,400 rows) for the same comparison. A 대운수 that is
  off by exactly one against another engine is almost always this.
- **`start_age_exact_years` is not `start_age_daeunsu` rounded, and the row says by how much.** The
  fraction is the raw 3-days-to-1-year value; the integer is the classical rule, which rounds a
  two-day remainder **up** to a whole year and never goes below 1 by convention.
  `start_age_daeunsu_minus_exact_years` carries the gap on every known-time row, and
  `start_age_daeunsu_floored_to_one` is `true` exactly when that floor lifted a near-zero count. On
  the same 6,000-birth sample the two differ by more than half a year in **19.7 %** of rows and by
  close to a full year in **1.0 %** — for example 1926-02-04 18:42 (female) emits
  `start_age_exact_years` **0.05474** beside `start_age_daeunsu` **1**, a gap of 0.94526, because
  the birth is 0.16 days from its reference 節. (The round-2 review measured about 5 % for this
  against that field as it then was, when it carried the rounded value and the only possible gap was
  that floor of 1; against the classical rule now emitted the rate is the 19.7 % above.) Both
  numbers are in the row — use the one your reading uses rather than expecting them to agree.
- **The 대운 list starts at the 1st 대운**, i.e. the month pillar ±1. The month pillar itself is not
  emitted as a 0th 대운 — some engines call that 초운/태운 and include it, so a buyer comparing
  outputs may see an off-by-one in the pillar list.
- **The 대운 pillars carry ages, not dates.** Converting a fractional start age into a calendar date
  requires an assumed year length, and no such constant is invented here.
- **Time zone facts are only as current as the runtime's tz database.** The offsets come from
  whatever IANA release the platform's Node carries, reported per row in `tzdb_version`;
  retroactive tzdb corrections to historical Korean offsets would change results.
- **Non-Korean births are supported but not specially tuned, and they need a longitude.** Any IANA
  zone works and the solar clocks use the longitude you pass — but a record that sets `timezone` to
  something other than the run's `defaultTimezone` and gives no `longitude` is **refused** with
  `record_status: "longitude_required"` instead of being charted on the default meridian. Pairing
  New York's offset with Seoul's meridian would move the four solar-clock conventions to a
  different place entirely — for a 1985-07-04 23:30 New York birth, a day pillar one day out and
  亥時 read as 午時 — so the record is refused instead. Regional conventions other than the seven
  above are not modelled.
- **Lunar-calendar conversion is absent by design, and refusal is the feature.** A record carrying
  `"calendar": "lunar"` is rejected with `record_status: "invalid_input"` rather than charted as if
  the date were solar, because silently treating a 음력 date as 양력 is wrong by up to a month.

***

### ⚖️ What this is and is not

Saju / 四柱 / BaZi is a **cultural and divinatory tradition**. This Actor is a calendar-and-
astronomy calculator for it: it converts timestamps into the traditional symbols and shows its
work. It makes no claim that the symbols predict anything, and it is **not advice** of any kind —
medical, financial, legal, relational or otherwise. Anything you tell your users about the
meaning of a chart is yours, not this Actor's.

***

### ❓ FAQ

**Does it scrape a 만세력 site?**
No. Nothing is fetched at run time. The pillars are computed from the sun's apparent position
(`astronomy-engine`, MIT) and the time-zone rules in the runtime's IANA tz database, reached
through Node's built-in `Intl`. That is also why it cannot break when a website changes.

**Is the output stable?**
Yes — identical input gives identical output on the same runtime. There is no timestamp, no
randomness, no remote call. The one external dependency is the tz database version, which is
reported in `tzdb_version` on every row.

**What does it cost?**
**$0.01 per run plus $0.005 per billable row** ($5.00 per 1,000 charts), plus negligible platform
compute — there is no browser, no proxy and no network traffic, and the Actor runs in 512 MB. One
chart on its own is $0.015; a 500-name batch in one run is $2.51. Rows rejected as `invalid_input`,
`out_of_range` or `longitude_required` are returned but not billed. Apify's free $5 monthly credit covers a single run of
about 998 charts, or about 333 one-chart runs.

**Why are seven hour pillars returned instead of the right one?**
Because the choice between clock time, mean local solar time and apparent solar time, and between
a midnight and a 23:00 day boundary, is genuinely disputed between schools — there is no neutral
authority to defer to. Returning all seven labelled values lets you apply your own school's rule
(or show the disagreement) instead of inheriting a hidden decision. The same reasoning is why both
branch 십신 bases, and the classical 대운수 beside both alternative roundings, come back in every row.

**Is the calculation itself audited?**
It re-audits itself on every run. Before charting anything the Actor re-asserts its load-bearing
constants: the 60-cycle bijection, four 지장간 structural invariants, the 五虎遁 and 五鼠遁 verses
for all ten stems, the 십신 rule against its sourced reference tables, the year-pillar epoch, and
the day-pillar epoch against four published anchors spanning 575 years — including a regression
guard against the widely repeated but false claim that 1984-02-02 was a 갑자 day (it was 병인;
1984 is the 갑자 **year**). If any of that fails the run refuses to produce charts rather than
producing plausible wrong ones. It also diffs the runtime's tz database against a bundled fixture
and warns on drift, because offsets are always read live from the runtime, never from the fixture.

**Why is a row's chart `null`?**
Because that wall-clock time never existed in that zone, or happened twice, and your policy is
`flag`. `local_time_note` says exactly which clock change caused it. Set `nonexistentTimePolicy`
or `ambiguousTimePolicy` if you want a chart anyway.

**Can I send 음력 (lunar) dates?**
Not directly — convert to 양력 first. See Known limitations.

**Can I batch thousands of records?**
Yes, that is the design: one row out per record in, in order, with your `id` echoed back. The
ceiling is 5,000 records per run — above that the run is refused rather than quietly truncated, so
you never receive a partial dataset you might mistake for a complete one. If the run's maximum
charge would be reached before the batch finishes, the log says so up front with the affordable
chart count — and because rows are pushed without a per-item charge, hitting the cap **stops the
billing, not the dataset**: the remaining rows are pushed free of charge and the run says how many
were delivered uncharged.

***

### 🤖 Use with AI agents (MCP)

Call this Actor as a tool from Claude or any MCP-compatible AI agent — no glue code. Point your
MCP client at Apify's server, scoped to this Actor:

```json
{
  "mcpServers": {
    "apify": {
      "url": "https://mcp.apify.com?tools=kdatafactory/saju-engine",
      "headers": { "Authorization": "Bearer <YOUR_APIFY_TOKEN>" }
    }
  }
}
```

Your agent can then compute four-pillar charts on demand instead of hallucinating them. Grab a
free token from [Apify → Integrations](https://console.apify.com/settings/integrations).

### 🇰🇷 More Korean data actors

This Actor is part of a suite of Korean-data Actors by the same maintainer:

- [korea-trend-feed](https://apify.com/kdatafactory/korea-trend-feed) — one cross-platform snapshot of what is trending in Korea.
- [kbeauty-rankings-feed](https://apify.com/kdatafactory/kbeauty-rankings-feed) — K-beauty rankings from three Korean sources in one schema.
- [naver-place-scraper](https://apify.com/kdatafactory/naver-place-scraper) — Korean business listings from Naver Place.

Browse all: [apify.com/kdatafactory](https://apify.com/kdatafactory)

***

### 🏃 Run it

On Apify: the input is pre-filled, so just click **Start**. Locally:

```bash
npm install
## put your input in storage/key_value_stores/default/INPUT.json
npm start
```

Results land in the default dataset (Apify) or `./storage/datasets/default` (local).

***

*If this Actor saves you time, a rating on the [Store page](https://apify.com/kdatafactory/saju-engine) helps a solo maintainer a lot. Found an issue, or want a convention this Actor does not model yet? Open it in the Issues tab — I respond fast.*

# Actor input Schema

## `births` (type: `array`):

The batch of births to chart, up to 5,000 records per run. SUPPORTED BIRTH YEARS ARE 1900-2100 BY CIVIL YEAR — the year in 'date', not the 사주 solar year — and a record outside that range comes back as an 'out\_of\_range' row with null pillars instead of a guess. The difference shows at both ends: a 1900-01-10 birth IS charted and reports solar\_year 1899 (입춘 1900 is on 1900-02-04), while the 丑月 of solar year 2100, which opens with 소한 on 2101-01-05 and runs to 입춘 on 2101-02-04, is NOT chartable, because those dates are civil 2101. The last chartable date is 2100-12-31, still inside 子月. One record in = exactly one dataset row out, in the same order, with your own 'id' echoed back so you can join results to your source table. Per record: 'date' (required, YYYY-MM-DD, Gregorian/양력 — convert lunar 음력 dates first; a record marked calendar:'lunar' is rejected, never guessed at), 'time' (HH:MM or HH:MM:SS, 24-hour; omit it if unknown and the hour pillar is returned as null rather than guessed), 'sex' ('male' or 'female' — needed only for 대운, whose direction follows 양남음녀; omit it and 'daeun' is null with a note), 'id' (any string key of yours), 'timezone' (IANA zone id, defaults below), 'longitude' (degrees east, used only for the solar-time conventions, defaults below — but REQUIRED on any record whose 'timezone' differs from the run's default zone, since the default longitude then belongs to a different place; such a record comes back as 'longitude\_required' instead of being charted on the wrong meridian). BILLING: charted rows and flagged rows (a local time that never existed or happened twice) are billed; rows rejected as 'invalid\_input', 'out\_of\_range' or 'longitude\_required' are still returned, so your join keeps one row per record, but they are not billed. The three pre-filled records are the cases worth seeing: an ordinary 1990 birth, a 23:40 birth in Korea's 1955 UTC+09:30 summer time, and a 1961-08-10 00:15 birth — a wall-clock time that never existed.

## `hourPillarConvention` (type: `string`):

Which convention fills the top-level 'hour\_pillar', 'day\_pillar', 'element\_counts', 'ten\_gods' and 'hidden\_stems' of each row. Every row always contains ALL seven conventions in 'hour\_pillar\_by\_convention', each labelled with what it did — this setting only nominates your default, and the default is a default, not a claim that it is the correct one. The two choices being combined are the time basis (the clock time as recorded, mean local solar time from the longitude alone, or true solar time which adds the equation of time) and the day boundary (00:00 or 23:00; this is the 야자시/조자시 discussion). Schools disagree, so this Actor reports every convention and ranks none of them.

## `tenGodsBranchBasis` (type: `string`):

A 지지 has no stem of its own, so reading its 십신 needs a choice, and the two defensible answers differ for exactly 子·午·巳·亥 — the four 體用 inversions. 'jeonggi' reads each branch through its 정기 지장간 (the 용 reading, what mainstream 만세력 display); 'positional' reads it by the branch's own 오행 with its positional polarity (the 체 reading). Every row returns BOTH: your choice in 'ten\_gods' and the other in 'ten\_gods\_branch\_alternate'. This setting only decides which is which.

## `daeunCount` (type: `integer`):

How many ten-year 대운 luck pillars to list per chart. 대운 needs the record's 'sex' (its direction follows 양남음녀) and is null without it. The distance to the governing 절기 is measured as pure instant arithmetic, so 대운수 does not depend on the hour-pillar convention you picked. The 1st 대운 is the month pillar plus or minus one — the month pillar itself is not emitted as a 0th 대운, which some engines call 초운/태운.

## `defaultTimezone` (type: `string`):

IANA time-zone id used for any record that omits 'timezone'. Historical offsets are read from the tz database that ships with the runtime — including Korea's UTC+08:30 eras (1908-04-01 to 1911-12-31 and 1954-03-20 to 1961-08-10), the local mean time of +08:27:52 before 1908-04-01, and the +09:30 summer time of 1955-1960 — never from a fixed meridian rule. Each row reports the offset it used and the tz database version.

## `defaultLongitude` (type: `number`):

Longitude in decimal degrees east, used for the mean-local-time and true-solar-time conventions when a record omits 'longitude'. 126.978 is Seoul. DEGREES ARE CONVERTED TO HOURS BY DIVIDING BY 15 (15° = 1 hour): mean local time = UTC + longitude/15 hours, i.e. the clock time shifted by (longitude - the meridian implied by the zone's actual offset)/15 hours; true solar time adds the equation of time. For Seoul's 126.978°E on a +09:00 clock that shift is about -32 minutes, and on the +08:30 clock of 1954-1961 it is about -2 minutes, not -32. Latitude is irrelevant to solar time, so none is asked for. Both components are reported per row.

## `nonexistentTimePolicy` (type: `string`):

Every forward clock jump erases a window of wall-clock time, and most of Korea's fall inside the 23:00-01:00 자시 hour (e.g. 1961-08-10 00:00-00:29 and 1912-01-01 00:00-00:29). 'flag' returns the row with null pillars, a note naming the clock change, and a warning — because the two possible readings of such a time usually fall on different days, and so give different day pillars. 'shift\_forward' instead charts the first local time that did exist after the gap and records that in 'resolution\_policy' and 'warnings'. Either way the row is pushed and billed — a row that names the clock change is the analysis, not a failure.

## `ambiguousTimePolicy` (type: `string`):

Every backward clock jump repeats a window of wall-clock time (e.g. 1954-03-20 23:30-23:59 in Seoul, again inside the 자시 hour). 'flag' returns the row with null pillars, a note, and BOTH instants in 'utc\_instant' and 'alternate\_utc\_instant'; 'earlier' or 'later' picks the first or second occurrence and records the choice in 'resolution\_policy' and 'warnings'. Either way the row is pushed and billed — a row that names the clock change is the analysis, not a failure.

## `boundaryFlagMinutes` (type: `integer`):

When a birth falls within this many minutes of a 절기 (節) instant, the row sets 'near\_term\_boundary' and adds a warning, because the month pillar turns on that instant. This threshold is a PRODUCT CHOICE and NOT a measured ephemeris error bound. What HAS been measured, term by term on 2026-09-29: the instants agree with the National Astronomical Observatory of Japan's 暦要項 (2025-2026) and the Hong Kong Observatory's tables (2026-2028) to within 59 seconds, and with NAOJ's 長期版 for 1912, 1961, 1990 and 2050 to within 48 seconds — but at 2100 they run 110-142 seconds EARLY, because the limit there is ΔT (the Earth's future rotation), which is predicted rather than computed and which NAOJ and this ephemeris predict differently. 한국천문연구원 (KASI)'s own published 절기 tables have still not been compared. Every row also carries the signed 'minutes\_to\_nearest\_term\_boundary' so you can apply your own threshold.

## Actor input object example

```json
{
  "births": [
    {
      "id": "A-1990",
      "date": "1990-06-15",
      "time": "13:10",
      "sex": "male"
    },
    {
      "id": "B-1955",
      "date": "1955-07-10",
      "time": "23:40",
      "sex": "male"
    },
    {
      "id": "C-1961",
      "date": "1961-08-10",
      "time": "00:15",
      "sex": "female"
    }
  ],
  "hourPillarConvention": "clock_midnight",
  "tenGodsBranchBasis": "jeonggi",
  "daeunCount": 10,
  "defaultTimezone": "Asia/Seoul",
  "defaultLongitude": 126.978,
  "nonexistentTimePolicy": "flag",
  "ambiguousTimePolicy": "flag",
  "boundaryFlagMinutes": 10
}
```

# Actor output Schema

## `results` (type: `string`):

Every birth chart computed in this run — one row per input record, in input order.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "births": [
        {
            "id": "A-1990",
            "date": "1990-06-15",
            "time": "13:10",
            "sex": "male"
        },
        {
            "id": "B-1955",
            "date": "1955-07-10",
            "time": "23:40",
            "sex": "male"
        },
        {
            "id": "C-1961",
            "date": "1961-08-10",
            "time": "00:15",
            "sex": "female"
        }
    ],
    "hourPillarConvention": "clock_midnight",
    "tenGodsBranchBasis": "jeonggi",
    "daeunCount": 10,
    "defaultTimezone": "Asia/Seoul",
    "defaultLongitude": 126.978,
    "nonexistentTimePolicy": "flag",
    "ambiguousTimePolicy": "flag",
    "boundaryFlagMinutes": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("kdatafactory/saju-engine").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "births": [
        {
            "id": "A-1990",
            "date": "1990-06-15",
            "time": "13:10",
            "sex": "male",
        },
        {
            "id": "B-1955",
            "date": "1955-07-10",
            "time": "23:40",
            "sex": "male",
        },
        {
            "id": "C-1961",
            "date": "1961-08-10",
            "time": "00:15",
            "sex": "female",
        },
    ],
    "hourPillarConvention": "clock_midnight",
    "tenGodsBranchBasis": "jeonggi",
    "daeunCount": 10,
    "defaultTimezone": "Asia/Seoul",
    "defaultLongitude": 126.978,
    "nonexistentTimePolicy": "flag",
    "ambiguousTimePolicy": "flag",
    "boundaryFlagMinutes": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("kdatafactory/saju-engine").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "births": [
    {
      "id": "A-1990",
      "date": "1990-06-15",
      "time": "13:10",
      "sex": "male"
    },
    {
      "id": "B-1955",
      "date": "1955-07-10",
      "time": "23:40",
      "sex": "male"
    },
    {
      "id": "C-1961",
      "date": "1961-08-10",
      "time": "00:15",
      "sex": "female"
    }
  ],
  "hourPillarConvention": "clock_midnight",
  "tenGodsBranchBasis": "jeonggi",
  "daeunCount": 10,
  "defaultTimezone": "Asia/Seoul",
  "defaultLongitude": 126.978,
  "nonexistentTimePolicy": "flag",
  "ambiguousTimePolicy": "flag",
  "boundaryFlagMinutes": 10
}' |
apify call kdatafactory/saju-engine --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kdatafactory/saju-engine"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/0FPb9ZATcJ0NJnPWK/builds/0iAO0d7HAW9EAt9tg/openapi.json
