From local time to a Located Moment
Turn a birth date, local time, and place into the UTC moment and coordinates Ascendant calculates from.
Every calculation in Ascendant starts from a Located Moment: one instant in UTC, plus the latitude and longitude where positions are calculated. People, though, give you a local date, a clock time, and a place. This guide covers the step in between: converting local time to UTC safely, handling times that are ambiguous or do not exist, and passing valid coordinates.
What the library expects
A Located Moment has three fields:
| Field | Type | Rule |
|---|---|---|
moment | Chart.Moment | Wraps one UTC DateTime. |
latitude | number | Decimal degrees, from -90 to 90. North is positive. |
longitude | number | Decimal degrees, from -180 to 180. East is positive, so west is negative. |
Ascendant never reads a time zone. It works only in UTC, and the same Located Moment always gives the same Placements. Getting from local time to UTC is your job, and it is where most mistakes happen.
Build a Moment
There are two common starting points. Both use Effect's DateTime.
From a timestamp that already has an offset
If your source gives an ISO 8601 string with an offset, parse it and you are done:
import { Chart } from "astro-ascendant";
import { DateTime, Option } from "effect";
const parsed = DateTime.make("2000-01-01T17:30:00+05:30");
// Option.some(2000-01-01T12:00:00.000Z)
const moment = Option.map(parsed, (date) => Chart.Moment.make({ date }));DateTime.make returns an Option, because the string might not be valid.
The result is already UTC.
From a local date, time, and time zone
If you have a wall-clock time and an IANA time zone name such as
Asia/Kolkata, convert it with DateTime.makeZoned:
const zoned = DateTime.makeZoned("2000-01-01T17:30:00", {
timeZone: "Asia/Kolkata",
adjustForTimeZone: true,
disambiguation: "reject",
});
const utc = Option.map(zoned, DateTime.toUtc);
// Option.some(2000-01-01T12:00:00.000Z)Two options matter here:
adjustForTimeZone: truereads the string as a local wall-clock time in that zone. Without it, the string is read as UTC and only labeled with the zone.disambiguation: "reject"returnsOption.none()for times that are ambiguous or do not exist. See the next section.
Local times that are ambiguous or do not exist
Clock changes create two kinds of bad local times:
| Case | Example in America/New_York | With "reject" |
|---|---|---|
| A gap, when clocks jump forward, so the time never happened | 2021-03-14 02:30 | Option.none() |
| An overlap, when clocks fall back, so the time happened twice | 2021-11-07 01:30 | Option.none() |
The other disambiguation modes ("earlier", "later", "compatible") silently
pick an answer. That can move a birth by an hour and change the Lagna. For
astrology, prefer "reject" and ask the user which of the two moments they mean,
or for the exact UTC offset from their birth record.
Convert in one function
Put the conversion in one place so every input takes the same path:
import { Chart } from "astro-ascendant";
import { DateTime, Effect, Option } from "effect";
export const toLocatedMoment = (input: {
readonly localDateTime: string; // "2000-01-01T17:30:00"
readonly timeZone: string; // IANA name, e.g. "Asia/Kolkata"
readonly latitude: number;
readonly longitude: number;
}): Effect.Effect<Chart.LocatedMoment, string> =>
Option.match(
DateTime.makeZoned(input.localDateTime, {
timeZone: input.timeZone,
adjustForTimeZone: true,
disambiguation: "reject",
}),
{
onNone: () =>
Effect.fail("That local time does not exist, or happens twice, in this time zone"),
onSome: (zoned) =>
Effect.succeed({
moment: Chart.Moment.make({ date: DateTime.toUtc(zoned) }),
latitude: input.latitude,
longitude: input.longitude,
}),
},
);It fails with a message you can show the user, and succeeds with a value you can
pass straight to Chart.generate:
const calculation = yield* toLocatedMoment(input).pipe(Effect.flatMap(Chart.generate));Find the time zone for a place
Ascendant does not map coordinates to a time zone, and the UTC offset depends on both the place and the date. A place can change its offset over time, and daylight saving rules differ by year. Look the zone up with a time zone lookup package, or ask for it, and always combine it with the birth date.
Historical births
DateTime uses the time zone data of the runtime it runs on. Rules for dates
before the mid-twentieth century, and for places that changed zones, can be
wrong or simplified in that data. If your birth record states the exact UTC
offset, pass it as an ISO string with the offset instead of converting from a
zone name.
Coordinates
Give latitude and longitude as decimal degrees. If you hold degrees, minutes, and
seconds, convert them first: degrees + minutes / 60 + seconds / 3600, negated
for south and west.
Chart.generate validates its input. A value outside the range fails with a typed
LocatedMomentValidationError, so a bad latitude does not produce a chart:
// latitude: 120 fails with:
// LocatedMomentValidationError:
// Expected a value between -90 and 90 at ["latitude"]Note that Chart.LocatedMoment.make throws on the same input, because it
validates immediately. Use a plain object, as the function above does, when you
want the failure to arrive as an Effect error instead of an exception.
Optional: the person's sex
Chart.generate also accepts Chart.ChartParams, which is a Located Moment with
an optional sex field. Ascendant copies it into each chart and does not use it
in any calculation.
A short checklist
- Convert local time with the birth date, not today's date.
- Use
disambiguation: "reject"and handleOption.none(). - Remember that longitudes west of Greenwich are negative.
- Pass the exact offset when you have it, especially for historical births.
- Keep the UTC moment and the original local input together if you will show the result back to the user.
