Ascendant
Developer guides

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:

FieldTypeRule
momentChart.MomentWraps one UTC DateTime.
latitudenumberDecimal degrees, from -90 to 90. North is positive.
longitudenumberDecimal 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: true reads 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" returns Option.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:

CaseExample in America/New_YorkWith "reject"
A gap, when clocks jump forward, so the time never happened2021-03-14 02:30Option.none()
An overlap, when clocks fall back, so the time happened twice2021-11-07 01:30Option.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 handle Option.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.

On this page