<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: GetBirthChart organization</title>
    <description>The latest articles on DEV Community by GetBirthChart organization (getbirthchart).</description>
    <link>https://dev.to/getbirthchart</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14449%2F4a2213c3-bc76-40a3-852e-191d28d83c82.png</url>
      <title>DEV Community: GetBirthChart organization</title>
      <link>https://dev.to/getbirthchart</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/getbirthchart"/>
    <language>en</language>
    <item>
      <title>Separating Deterministic Calculations from AI Interpretation</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:24:18 +0000</pubDate>
      <link>https://dev.to/getbirthchart/separating-deterministic-calculations-from-ai-interpretation-85f</link>
      <guid>https://dev.to/getbirthchart/separating-deterministic-calculations-from-ai-interpretation-85f</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3qsbu7f9iqq058flmaj1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3qsbu7f9iqq058flmaj1.jpg" alt=" " width="800" height="676"&gt;&lt;/a&gt;One rule in GetBirthChart has become more important as I’ve added more AI features:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI does not get to invent the chart.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It can explain the chart.&lt;/p&gt;

&lt;p&gt;It can compare things in the chart.&lt;/p&gt;

&lt;p&gt;It can synthesize several placements into a larger pattern.&lt;/p&gt;

&lt;p&gt;But the underlying facts come from code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The easiest architecture is also the one I didn't want
&lt;/h2&gt;

&lt;p&gt;You could build an astrology AI product like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User:
"I was born on Nov 3, 1992 at 2:35 PM in Hanoi."

        ↓

LLM:
"Calculate the birth chart and explain it."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Very little infrastructure.&lt;/p&gt;

&lt;p&gt;Very impressive demo.&lt;/p&gt;

&lt;p&gt;The problem is that one model is now responsible for two completely different jobs.&lt;/p&gt;

&lt;p&gt;First:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Where was Saturn?
What was the Ascendant?
Which house contained Venus?
Is Mars retrograde?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What might those things mean together?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first group has deterministic answers under a given calculation methodology.&lt;/p&gt;

&lt;p&gt;The second group is interpretation.&lt;/p&gt;

&lt;p&gt;I don't want them mixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture is deliberately less magical
&lt;/h2&gt;

&lt;p&gt;GetBirthChart works more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;birth input
    ↓
Python calculation engine
    ↓
canonical chart facts
    ↓
evidence/context selection
    ↓
LLM
    ↓
interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python engine uses Swiss Ephemeris and produces structured chart data.&lt;/p&gt;

&lt;p&gt;The model receives that data.&lt;/p&gt;

&lt;p&gt;So instead of asking the LLM:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which sign is the Moon in?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I can give it something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aquarius"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"degreeInSign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;21.29&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"house"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The language model has a much more appropriate job now:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Explain this placement in the context of the other available chart evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  This gives the system a source of truth
&lt;/h2&gt;

&lt;p&gt;Imagine the engine returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Moon: Aquarius
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the generated interpretation starts talking about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your Scorpio Moon
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s a contradiction I can detect.&lt;/p&gt;

&lt;p&gt;Without a deterministic chart model, there isn’t a clean authority to compare the output against.&lt;/p&gt;

&lt;p&gt;The LLM becomes the calculator, database and writer at the same time.&lt;/p&gt;

&lt;p&gt;That makes QA much harder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Missing data is even more important
&lt;/h2&gt;

&lt;p&gt;Unknown birth time is a good example.&lt;/p&gt;

&lt;p&gt;If birth time is unknown, the engine intentionally omits time-dependent fields.&lt;/p&gt;

&lt;p&gt;The model might receive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"birthTimeKnown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"angles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"houses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"warnings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UNKNOWN_BIRTH_TIME"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no Rising sign in the evidence.&lt;/p&gt;

&lt;p&gt;There is no house placement.&lt;/p&gt;

&lt;p&gt;That’s much stronger than putting this sentence in the prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not make up an Ascendant.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Prompt instructions are useful.&lt;/p&gt;

&lt;p&gt;Structural absence is better.&lt;/p&gt;

&lt;h2&gt;
  
  
  I like evidence-based prompts more than giant context dumps
&lt;/h2&gt;

&lt;p&gt;Another pattern I’ve been using is selecting chart facts as evidence instead of dumping every available field into every generation.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon.sign"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Aquarius"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon.house"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon.square.saturn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"orb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.2&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a piece of interpretation can be tied back to actual chart facts.&lt;/p&gt;

&lt;p&gt;This is useful for the UI too.&lt;/p&gt;

&lt;p&gt;A user can eventually ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why are you saying this?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and the product has something better to show than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The AI thought so.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It can show the relevant calculated evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI gets better when I give it less authority
&lt;/h2&gt;

&lt;p&gt;This was a little counterintuitive.&lt;/p&gt;

&lt;p&gt;The instinct with LLMs is to give them more.&lt;/p&gt;

&lt;p&gt;More context.&lt;/p&gt;

&lt;p&gt;More freedom.&lt;/p&gt;

&lt;p&gt;More responsibility.&lt;/p&gt;

&lt;p&gt;But I’ve found the product gets more reliable when the model owns fewer deterministic decisions.&lt;/p&gt;

&lt;p&gt;I want code to handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timezone conversion
planet positions
houses
aspect geometry
retrograde
schema validation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want the LLM to handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;explanation
synthesis
comparison
follow-up reasoning
language
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That division is much easier to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  This isn't specific to astrology
&lt;/h2&gt;

&lt;p&gt;The same architecture makes sense in other domains.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQL query
   ↓
actual metrics
   ↓
AI explanation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compiler / AST
   ↓
structured findings
   ↓
AI explanation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deterministic pricing engine
   ↓
quote
   ↓
AI explains the quote
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something important can be calculated or retrieved deterministically, I increasingly prefer doing that before the model sees it.&lt;/p&gt;

&lt;p&gt;Then the AI can work with evidence instead of pretending to be the evidence source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use now
&lt;/h2&gt;

&lt;p&gt;The rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Code determines what is in the chart.&lt;br&gt;
AI helps explain what the chart may mean.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That doesn’t eliminate hallucinations.&lt;/p&gt;

&lt;p&gt;It doesn’t solve every quality problem.&lt;/p&gt;

&lt;p&gt;But it gives the system a stable foundation.&lt;/p&gt;

&lt;p&gt;And once you have that, everything from testing to UI explanations gets easier.&lt;/p&gt;

&lt;p&gt;The calculation engine behind this architecture is open source here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hosted product using it is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://getbirthchart.com/" rel="noopener noreferrer"&gt;https://getbirthchart.com/&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Model Aspects and Orbs in a Python Astrology Engine</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:18:47 +0000</pubDate>
      <link>https://dev.to/getbirthchart/how-i-model-aspects-and-orbs-in-a-python-astrology-engine-pob</link>
      <guid>https://dev.to/getbirthchart/how-i-model-aspects-and-orbs-in-a-python-astrology-engine-pob</guid>
      <description>&lt;p&gt;I like working on aspect calculations because the astrology terminology disappears pretty quickly once you get into the code.&lt;/p&gt;

&lt;p&gt;At the calculation level, an aspect is basically:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How close are two points on a circle to a configured angle?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That turns the problem into geometry, tolerances and a few interesting edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with angular distance
&lt;/h2&gt;

&lt;p&gt;Suppose two planets have longitudes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12°
102°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Their separation is 90°.&lt;/p&gt;

&lt;p&gt;That’s easy.&lt;/p&gt;

&lt;p&gt;But this pair is more interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;358°
2°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal absolute difference gives you 356°.&lt;/p&gt;

&lt;p&gt;On a circle, they’re actually 4° apart.&lt;/p&gt;

&lt;p&gt;So one of the basic utilities looks conceptually like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;angular_distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;angular_distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;358&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That simple normalization is the base of the rest of the aspect system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then define target angles
&lt;/h2&gt;

&lt;p&gt;For the major aspects, you’re comparing against angles such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0°    conjunction
60°   sextile
90°   square
120°  trine
180°  opposition
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything had to be exact, the implementation would be trivial.&lt;/p&gt;

&lt;p&gt;But astrology uses orbs.&lt;/p&gt;

&lt;p&gt;So a separation of 92° can still be treated as a square depending on the calculation profile.&lt;/p&gt;

&lt;p&gt;The orb is basically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual_distance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;target_angle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;orb&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;allowed_orb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# aspect matched
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  I keep orb rules in a profile
&lt;/h2&gt;

&lt;p&gt;This is one of those places where hidden constants are really tempting.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MAX_ORB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and move on.&lt;/p&gt;

&lt;p&gt;I prefer putting this kind of behavior into an explicit calculation profile.&lt;/p&gt;

&lt;p&gt;That way the result isn’t just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Venus square Saturn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can be understood as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Venus square Saturn
under this aspect profile
with this orb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the methodology easier to inspect and makes future changes much less messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engine reports geometry, not sentiment
&lt;/h2&gt;

&lt;p&gt;This was another boundary I wanted to keep clean.&lt;/p&gt;

&lt;p&gt;The core can calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;planet A
planet B
aspect type
orb
phase
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should not calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;good
bad
easy
terrible relationship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are interpretation-layer concepts.&lt;/p&gt;

&lt;p&gt;I don’t want this in the chart model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aspect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"square"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meaning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"negative"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The geometry doesn’t know that.&lt;/p&gt;

&lt;p&gt;It knows there is a 90-degree relationship within the configured tolerance.&lt;/p&gt;

&lt;p&gt;That separation makes the output usable by different interpretation systems later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying vs separating
&lt;/h2&gt;

&lt;p&gt;Once the static aspect is identified, there’s another useful piece of information: motion.&lt;/p&gt;

&lt;p&gt;Is the pair moving toward exactness or away from it?&lt;/p&gt;

&lt;p&gt;For that you need more than longitude.&lt;/p&gt;

&lt;p&gt;You need the bodies’ motion as well.&lt;/p&gt;

&lt;p&gt;The ephemeris provides longitudinal speed, which lets the engine derive whether the aspect is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;applying
exact
separating
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the structured result can look conceptually like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"square"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"applying"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application can decide whether and how to interpret that.&lt;/p&gt;

&lt;p&gt;Again, the core just reports the calculated state.&lt;/p&gt;

&lt;h2&gt;
  
  
  This code is nice to test
&lt;/h2&gt;

&lt;p&gt;Aspect math gives you some good invariants.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distance(a, b) == distance(b, a)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distance must always be in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0° &amp;lt;= distance &amp;lt;= 180°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And wraparound behavior needs direct tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;359° vs 1° = 2°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Orb boundaries are useful too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orb == maximum → match
orb &amp;gt; maximum → no match
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the kinds of tests I trust much more than checking a few screenshots from the frontend.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pattern I keep using
&lt;/h2&gt;

&lt;p&gt;This part of the engine is a good example of how I like to structure domain logic.&lt;/p&gt;

&lt;p&gt;Start with the smallest deterministic representation.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Venus square Saturn means relationship restriction.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Venus longitude
Saturn longitude
angular separation
target angle
allowed tolerance
motion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get that part right first.&lt;/p&gt;

&lt;p&gt;Then let another layer decide what, if anything, the relationship means.&lt;/p&gt;

&lt;p&gt;It keeps the calculation code boring.&lt;/p&gt;

&lt;p&gt;I mean that as a compliment.&lt;/p&gt;

&lt;p&gt;The aspect implementation is part of the open-source GetBirthChart Python engine:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Hard Part of Birth Chart Calculations Isn't the Zodiac. It's Time.</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:16:17 +0000</pubDate>
      <link>https://dev.to/getbirthchart/the-hard-part-of-birth-chart-calculations-isnt-the-zodiac-its-time-5h06</link>
      <guid>https://dev.to/getbirthchart/the-hard-part-of-birth-chart-calculations-isnt-the-zodiac-its-time-5h06</guid>
      <description>&lt;p&gt;The most annoying bugs I’ve dealt with while building a birth-chart engine were not about zodiac signs.&lt;/p&gt;

&lt;p&gt;They were about time.&lt;/p&gt;

&lt;p&gt;And the deeper I got into it, the more I realized that “birth time” is a much less simple input than it looks on a form.&lt;/p&gt;

&lt;h2&gt;
  
  
  A local datetime isn't enough
&lt;/h2&gt;

&lt;p&gt;Take this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1990-05-15 09:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks precise.&lt;/p&gt;

&lt;p&gt;But precise where?&lt;/p&gt;

&lt;p&gt;Without a timezone, it doesn’t identify an instant.&lt;/p&gt;

&lt;p&gt;So the calculation API I use takes both the local datetime and the IANA timezone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1990-05-15T09:30:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/London&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;51.5074&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mf"&gt;0.1278&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer an IANA zone like &lt;code&gt;Europe/London&lt;/code&gt; over something like &lt;code&gt;UTC+1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The former describes a real timezone with historical rules.&lt;/p&gt;

&lt;p&gt;The latter is just an offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  DST makes this more interesting
&lt;/h2&gt;

&lt;p&gt;During a daylight-saving fall-back transition, the same local clock time can occur twice.&lt;/p&gt;

&lt;p&gt;So a value like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;may correspond to two different UTC instants.&lt;/p&gt;

&lt;p&gt;That means the input looks exact to the user while still being ambiguous to the calculation engine.&lt;/p&gt;

&lt;p&gt;There’s a strong temptation to quietly choose one.&lt;/p&gt;

&lt;p&gt;I don’t like doing that.&lt;/p&gt;

&lt;p&gt;If the input is genuinely ambiguous, I’d rather make the ambiguity explicit.&lt;/p&gt;

&lt;p&gt;The same thing happens in the opposite direction during spring transitions.&lt;/p&gt;

&lt;p&gt;Some local clock times never existed.&lt;/p&gt;

&lt;p&gt;If the clock jumped directly from 01:59 to 03:00, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;02:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;isn’t a valid local instant.&lt;/p&gt;

&lt;p&gt;Again, silently “fixing” it is convenient.&lt;/p&gt;

&lt;p&gt;But now the software has changed the user’s data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then there’s the bigger problem: no birth time
&lt;/h2&gt;

&lt;p&gt;A lot of people simply don’t know what time they were born.&lt;/p&gt;

&lt;p&gt;This creates a product decision.&lt;/p&gt;

&lt;p&gt;You can say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown → 12:00 PM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and suddenly everything works.&lt;/p&gt;

&lt;p&gt;You get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ascendant&lt;/li&gt;
&lt;li&gt;houses&lt;/li&gt;
&lt;li&gt;house cusps&lt;/li&gt;
&lt;li&gt;Midheaven&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The object looks complete.&lt;/p&gt;

&lt;p&gt;But none of those values are based on a birth time the person actually supplied.&lt;/p&gt;

&lt;p&gt;That bothered me enough that I made unknown birth time a first-class state in the engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unknown_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result intentionally omits time-dependent fields.&lt;/p&gt;

&lt;p&gt;No fake Rising sign.&lt;/p&gt;

&lt;p&gt;No fake houses.&lt;/p&gt;

&lt;p&gt;No fake precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incomplete output can be more correct
&lt;/h2&gt;

&lt;p&gt;Developers generally like complete data structures.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ascendant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cancer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"house"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;feels satisfying.&lt;/p&gt;

&lt;p&gt;This:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ascendant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"house"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;feels like a failure.&lt;/p&gt;

&lt;p&gt;But if the user doesn’t know the time, the second object is actually saying something more truthful.&lt;/p&gt;

&lt;p&gt;The system is distinguishing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I know the value.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I have a field available where a value could go.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those aren’t the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Moon makes it even more interesting
&lt;/h2&gt;

&lt;p&gt;Even without a birth time, you can still calculate a lot from the date.&lt;/p&gt;

&lt;p&gt;But not everything has the same level of certainty.&lt;/p&gt;

&lt;p&gt;The Moon moves fast enough that its exact degree — and occasionally even its sign near a boundary — can depend on the time of day.&lt;/p&gt;

&lt;p&gt;So unknown-time handling can’t just be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;remove the Ascendant and everything else is exact.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You need to communicate uncertainty where it actually exists.&lt;/p&gt;

&lt;p&gt;That’s one reason I like carrying warnings as structured output from the core rather than bolting them onto the UI later.&lt;/p&gt;

&lt;p&gt;The UI should be reflecting the calculation state, not inventing its own policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  This changed how I think about UX
&lt;/h2&gt;

&lt;p&gt;Initially, I thought timezone and missing-time behavior were backend concerns.&lt;/p&gt;

&lt;p&gt;Now I think they’re part of the product experience.&lt;/p&gt;

&lt;p&gt;When the site tells someone:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Rising sign requires a reliable birth time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;that should not be marketing copy.&lt;/p&gt;

&lt;p&gt;It should describe an actual engine constraint.&lt;/p&gt;

&lt;p&gt;That connection is important to me.&lt;/p&gt;

&lt;p&gt;The best trust messages in software are usually backed by behavior.&lt;/p&gt;

&lt;p&gt;Not promises.&lt;/p&gt;

&lt;h2&gt;
  
  
  This isn’t really an astrology-specific lesson
&lt;/h2&gt;

&lt;p&gt;The same problem appears whenever software works with incomplete or uncertain inputs.&lt;/p&gt;

&lt;p&gt;Geolocation.&lt;/p&gt;

&lt;p&gt;Financial records.&lt;/p&gt;

&lt;p&gt;Sensor data.&lt;/p&gt;

&lt;p&gt;AI-extracted structured fields.&lt;/p&gt;

&lt;p&gt;User demographics.&lt;/p&gt;

&lt;p&gt;There’s always a temptation to turn missing data into complete-looking data because downstream code becomes easier.&lt;/p&gt;

&lt;p&gt;Sometimes the better API is the one willing to say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I’ve become much more comfortable with that while building this engine.&lt;/p&gt;

&lt;p&gt;The Python core is open source here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you work with timezone-heavy applications, the astrology part is probably the least interesting thing in that repo. The time-handling decisions are where a lot of the real engineering lives.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
    </item>
    <item>
      <title>Building a Natal Chart Engine in Python: From Birth Data to Planetary Positions</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:24:22 +0000</pubDate>
      <link>https://dev.to/getbirthchart/building-a-natal-chart-engine-in-python-from-birth-data-to-planetary-positions-4156</link>
      <guid>https://dev.to/getbirthchart/building-a-natal-chart-engine-in-python-from-birth-data-to-planetary-positions-4156</guid>
      <description>&lt;p&gt;When I started working on a natal-chart engine, I assumed the interesting part would be zodiac calculations.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;Mapping a longitude to a zodiac sign is one of the easier parts.&lt;/p&gt;

&lt;p&gt;The harder problem is building a pipeline where every step has an explicit meaning.&lt;/p&gt;

&lt;p&gt;The input a user gives you is usually something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;November 3, 1992
2:35 PM
Hanoi, Vietnam
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The astronomical layer needs something much less human:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UTC instant
latitude
longitude
calculation profile
ephemeris
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job of the engine is to get from one to the other without quietly making decisions the caller didn't ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: keep local time and timezone separate
&lt;/h2&gt;

&lt;p&gt;One thing I deliberately avoid is treating a local datetime as if it already describes an absolute instant.&lt;/p&gt;

&lt;p&gt;This:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1992-11-03 14:35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is incomplete.&lt;/p&gt;

&lt;p&gt;You still need a timezone.&lt;/p&gt;

&lt;p&gt;In the engine interface, they're separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03T14:35:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timezone uses an IANA identifier rather than a fixed UTC offset.&lt;/p&gt;

&lt;p&gt;That matters because historical timezone and DST rules aren't always equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UTC+7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: convert the local civil time to an astronomical instant
&lt;/h2&gt;

&lt;p&gt;Once the local datetime has been resolved, the calculation layer can convert it to UTC and then to the time representation expected by the ephemeris.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local civil time
      ↓
timezone rules
      ↓
UTC
      ↓
Julian day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are edge cases here that are easy to miss.&lt;/p&gt;

&lt;p&gt;During a DST fall-back transition, the same clock time may occur twice.&lt;/p&gt;

&lt;p&gt;During spring-forward, some clock times don't exist at all.&lt;/p&gt;

&lt;p&gt;I prefer rejecting an ambiguous input to silently choosing one possibility.&lt;/p&gt;

&lt;p&gt;This is a general engineering principle I've become more attached to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Ambiguity should be represented in the API, not hidden inside a default.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3: ask an ephemeris for planetary positions
&lt;/h2&gt;

&lt;p&gt;The engine uses Swiss Ephemeris through &lt;code&gt;pyswisseph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For each supported body, the provider gives us values including ecliptic longitude and longitude speed.&lt;/p&gt;

&lt;p&gt;A longitude might conceptually look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;221.1415°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we're finally at the familiar astrology part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: longitude → sign + degree
&lt;/h2&gt;

&lt;p&gt;A zodiac is a 360° circle divided into 12 sections of 30°.&lt;/p&gt;

&lt;p&gt;So the basic mapping is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sign_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longitude&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;degree_in_sign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;longitude&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;221.1415°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;lands in the eighth 30° segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scorpio 11.1415°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This part is deterministic and uncomplicated.&lt;/p&gt;

&lt;p&gt;Which is exactly why I don't want an LLM doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: calculate houses and angles
&lt;/h2&gt;

&lt;p&gt;Now things get more sensitive to time and location.&lt;/p&gt;

&lt;p&gt;For a known birth time, the engine can calculate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ascendant&lt;/li&gt;
&lt;li&gt;Midheaven&lt;/li&gt;
&lt;li&gt;Descendant&lt;/li&gt;
&lt;li&gt;IC&lt;/li&gt;
&lt;li&gt;house cusps&lt;/li&gt;
&lt;li&gt;planet-to-house assignments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The default profile uses Placidus, although the core supports multiple house systems.&lt;/p&gt;

&lt;p&gt;This is where latitude also becomes important.&lt;/p&gt;

&lt;p&gt;Some house systems have geographic limits.&lt;/p&gt;

&lt;p&gt;For example, I prefer raising an explicit calculation error in unsupported polar conditions rather than silently switching the user to another house system.&lt;/p&gt;

&lt;p&gt;Again: no invisible fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: derive aspects
&lt;/h2&gt;

&lt;p&gt;Once the body positions are available, aspects become a circular-distance problem.&lt;/p&gt;

&lt;p&gt;For two longitudes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;358.0&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the distance is not 356°.&lt;/p&gt;

&lt;p&gt;It's 4°.&lt;/p&gt;

&lt;p&gt;A common normalization is conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
&lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the engine compares the distance to configured aspect angles.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;conjunction   0°
sextile      60°
square       90°
trine       120°
opposition  180°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with different orb allowances.&lt;/p&gt;

&lt;p&gt;The engine can then publish structured facts such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"square"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"applying"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of forcing the interpretation layer to rediscover the geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: normalize everything into one chart model
&lt;/h2&gt;

&lt;p&gt;This is one of the parts I underestimated.&lt;/p&gt;

&lt;p&gt;The astronomy isn't enough.&lt;/p&gt;

&lt;p&gt;The application needs a stable contract.&lt;/p&gt;

&lt;p&gt;The engine eventually turns the calculations into a normalized chart object containing things like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject
meta
bodies
angles
houses
aspects
derived facts
warnings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That model is what the rest of GetBirthChart consumes.&lt;/p&gt;

&lt;p&gt;The frontend shouldn't need to know how Swiss Ephemeris works.&lt;/p&gt;

&lt;p&gt;The AI layer shouldn't need to know how to normalize circular angles.&lt;/p&gt;

&lt;p&gt;The API shouldn't implement its own astrology math.&lt;/p&gt;

&lt;p&gt;They all consume the same chart model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this separation is useful
&lt;/h2&gt;

&lt;p&gt;It creates a very clear dependency graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Swiss Ephemeris
      ↓
Python calculation core
      ↓
canonical chart model
      ↓
API / UI / interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a planet position is wrong, I debug the core.&lt;/p&gt;

&lt;p&gt;If the API serialized it incorrectly, I debug the transport.&lt;/p&gt;

&lt;p&gt;If an interpretation is poor, I debug the interpretation layer.&lt;/p&gt;

&lt;p&gt;Without those boundaries, bugs become much harder to classify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprising lesson
&lt;/h2&gt;

&lt;p&gt;The interesting part of an astrology engine isn't really “how do I calculate Scorpio?”&lt;/p&gt;

&lt;p&gt;It's how you handle everything around the calculation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;civil time&lt;/li&gt;
&lt;li&gt;DST ambiguity&lt;/li&gt;
&lt;li&gt;unknown inputs&lt;/li&gt;
&lt;li&gt;geographic limitations&lt;/li&gt;
&lt;li&gt;calculation profiles&lt;/li&gt;
&lt;li&gt;structured errors&lt;/li&gt;
&lt;li&gt;deterministic output&lt;/li&gt;
&lt;li&gt;test fixtures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The zodiac mapping is maybe ten lines.&lt;/p&gt;

&lt;p&gt;The trustworthiness of the software lives in everything around those ten lines.&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why I Open-Sourced the Python Calculation Engine Behind GetBirthChart</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:21:32 +0000</pubDate>
      <link>https://dev.to/getbirthchart/why-i-open-sourced-the-python-calculation-engine-behind-getbirthchart-4cb2</link>
      <guid>https://dev.to/getbirthchart/why-i-open-sourced-the-python-calculation-engine-behind-getbirthchart-4cb2</guid>
      <description>&lt;p&gt;I’ve been building GetBirthChart for a while, and one thing kept bothering me.&lt;/p&gt;

&lt;p&gt;From the outside, a birth chart app can look like one big black box.&lt;/p&gt;

&lt;p&gt;You enter a date, time and place. A chart appears. Then an interpretation appears.&lt;/p&gt;

&lt;p&gt;But those are actually two very different problems.&lt;/p&gt;

&lt;p&gt;One is calculation.&lt;/p&gt;

&lt;p&gt;The other is interpretation.&lt;/p&gt;

&lt;p&gt;I wanted that boundary to be obvious, not just in the UI, but in the architecture itself.&lt;/p&gt;

&lt;p&gt;So I open-sourced the Python calculation engine behind GetBirthChart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part AI shouldn’t be doing
&lt;/h2&gt;

&lt;p&gt;GetBirthChart uses AI for interpretation.&lt;/p&gt;

&lt;p&gt;But I don’t want an LLM deciding where the Moon was when someone was born.&lt;/p&gt;

&lt;p&gt;That belongs in deterministic code.&lt;/p&gt;

&lt;p&gt;The architecture is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;birth data
   ↓
Python calculation engine
   ↓
structured chart data
   ↓
application / interpretation layer
   ↓
AI + UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python layer calculates the chart.&lt;/p&gt;

&lt;p&gt;The AI layer receives the result and explains it.&lt;/p&gt;

&lt;p&gt;That distinction sounds simple, but it changes a lot.&lt;/p&gt;

&lt;p&gt;If the interpretation says someone has a Scorpio Moon, there should be a calculated Moon placement behind that statement.&lt;/p&gt;

&lt;p&gt;The model shouldn’t “know” the placement from training data or try to reconstruct it itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the engine handles
&lt;/h2&gt;

&lt;p&gt;The core is written in Python and uses Swiss Ephemeris for the astronomical calculations.&lt;/p&gt;

&lt;p&gt;It handles things such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;planetary positions&lt;/li&gt;
&lt;li&gt;zodiac placements&lt;/li&gt;
&lt;li&gt;houses&lt;/li&gt;
&lt;li&gt;Ascendant and Midheaven&lt;/li&gt;
&lt;li&gt;aspects and orbs&lt;/li&gt;
&lt;li&gt;retrograde state&lt;/li&gt;
&lt;li&gt;normalized chart output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A basic calculation looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gbc_astro&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AstrologyEngine&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AstrologyEngine&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03T14:35:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sun&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is not really that you can turn 221 degrees of longitude into Scorpio.&lt;/p&gt;

&lt;p&gt;That part is easy.&lt;/p&gt;

&lt;p&gt;The difficult parts tend to be around the edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timezone conversion&lt;/li&gt;
&lt;li&gt;daylight saving transitions&lt;/li&gt;
&lt;li&gt;unknown birth times&lt;/li&gt;
&lt;li&gt;house-system limits&lt;/li&gt;
&lt;li&gt;circular angle math&lt;/li&gt;
&lt;li&gt;aspect tolerances&lt;/li&gt;
&lt;li&gt;making every assumption explicit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are also the things I wanted people to be able to inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why make it public?
&lt;/h2&gt;

&lt;p&gt;Partly because I think calculation software should be inspectable.&lt;/p&gt;

&lt;p&gt;If someone sees a chart result and asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why did this calculator give me this Ascendant?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;“Trust the website” isn’t a very useful answer.&lt;/p&gt;

&lt;p&gt;With the engine public, the actual calculation path can be inspected and tested.&lt;/p&gt;

&lt;p&gt;But there was another benefit I didn’t expect as much.&lt;/p&gt;

&lt;p&gt;Open-sourcing it forced me to clean up the architecture.&lt;/p&gt;

&lt;p&gt;Private code can survive with assumptions that only the original developer understands.&lt;/p&gt;

&lt;p&gt;Public code is much less forgiving.&lt;/p&gt;

&lt;p&gt;I had to make things like calculation profiles, errors, warnings and output structures clearer.&lt;/p&gt;

&lt;p&gt;That made the production system better too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown birth time is a good example
&lt;/h2&gt;

&lt;p&gt;This was one of the design choices I cared about from the beginning.&lt;/p&gt;

&lt;p&gt;If someone says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don’t know what time I was born.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;the easiest implementation is to pick a default.&lt;/p&gt;

&lt;p&gt;Noon is common.&lt;/p&gt;

&lt;p&gt;Midnight is common too.&lt;/p&gt;

&lt;p&gt;That gives you a nice complete chart.&lt;/p&gt;

&lt;p&gt;It also creates a Rising sign and houses from a birth time the user never gave you.&lt;/p&gt;

&lt;p&gt;I didn’t want to do that.&lt;/p&gt;

&lt;p&gt;The engine has an explicit unknown-time mode instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unknown_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time-dependent parts of the chart are then omitted rather than silently fabricated.&lt;/p&gt;

&lt;p&gt;That means the resulting object may be less complete.&lt;/p&gt;

&lt;p&gt;I think that’s a feature.&lt;/p&gt;

&lt;p&gt;I’d rather return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ascendant: unavailable
Houses: unavailable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;than return confident-looking information based on a fake noon birth time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source isn’t a claim that astrology is science
&lt;/h2&gt;

&lt;p&gt;This is another boundary I want to keep clear.&lt;/p&gt;

&lt;p&gt;Publishing the code makes the software inspectable.&lt;/p&gt;

&lt;p&gt;It doesn’t prove astrology.&lt;/p&gt;

&lt;p&gt;The engine answers a technical question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given these inputs and these calculation rules, what chart does the software produce?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Interpretation is a different layer.&lt;/p&gt;

&lt;p&gt;GetBirthChart treats that layer as a reflective framework rather than a scientific prediction system.&lt;/p&gt;

&lt;p&gt;I think the product becomes more honest when those two things are separated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger lesson for me
&lt;/h2&gt;

&lt;p&gt;The part I like most about this architecture has very little to do with astrology.&lt;/p&gt;

&lt;p&gt;It’s this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deterministic domain logic
        ↓
structured evidence
        ↓
AI interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I’m using the same idea more and more in products that involve LLMs.&lt;/p&gt;

&lt;p&gt;If something has a deterministic answer, calculate it outside the model.&lt;/p&gt;

&lt;p&gt;Give the model good evidence.&lt;/p&gt;

&lt;p&gt;Then let the model do what it’s actually good at: explaining, comparing and synthesizing.&lt;/p&gt;

&lt;p&gt;The Python engine is now public here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the hosted implementation is what powers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GetBirthChart:&lt;/strong&gt; &lt;a href="https://getbirthchart.com/" rel="noopener noreferrer"&gt;https://getbirthchart.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’ll probably write more about the less obvious parts of building it, especially timezone handling, unknown birth times and aspect calculations. Those turned out to be much more interesting engineering problems than I expected.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>python</category>
    </item>
  </channel>
</rss>
