feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
// Package star builds 星座 reports (natal chart · fortune · deep copy).
|
||||
package star
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/fortune"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
// Output is free summary + gated detail.
|
||||
type Output struct {
|
||||
Summary map[string]any `json:"summary"`
|
||||
Detail map[string]any `json:"detail"`
|
||||
}
|
||||
|
||||
// BuildOpts configures report generation.
|
||||
type BuildOpts struct {
|
||||
Birth time.Time
|
||||
BirthTime *string
|
||||
BirthPlace *string
|
||||
Name string
|
||||
AsOf time.Time // fortune anchor; zero = now
|
||||
}
|
||||
|
||||
// Build generates StarProfile from birth date (compat wrapper).
|
||||
func Build(birth time.Time, birthTime *string, displayName string) (Output, error) {
|
||||
return BuildWith(BuildOpts{Birth: birth, BirthTime: birthTime, Name: displayName})
|
||||
}
|
||||
|
||||
// BuildWith generates a full star report.
|
||||
func BuildWith(opts BuildOpts) (Output, error) {
|
||||
name := opts.Name
|
||||
if name == "" {
|
||||
name = "你"
|
||||
}
|
||||
chart, err := natal.Compute(opts.Birth, opts.BirthTime, opts.BirthPlace)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
sun := chart.Sun
|
||||
moon := chart.Moon
|
||||
rise := chart.Rise
|
||||
pack := packs[sun.SignKey]
|
||||
if pack.Label == "" {
|
||||
pack = defaultPack(signMeta{Key: sun.SignKey, Label: sun.Sign, Element: sun.Element, Modality: sun.Modality})
|
||||
}
|
||||
moonPack := packs[moon.SignKey]
|
||||
risePack := packs[rise.SignKey]
|
||||
|
||||
signCards := []map[string]any{
|
||||
signCard("sun", "太阳星座", sun, pack.SunTeaser, pack.Keywords),
|
||||
signCard("moon", "月亮星座", moon, fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), moonPack.Keywords),
|
||||
signCard("rise", "上升星座", rise, fmt.Sprintf("第一印象更偏「%s」", rise.Sign), risePack.Keywords),
|
||||
}
|
||||
|
||||
dims := []map[string]any{
|
||||
{"key": "sun", "title": "太阳风格", "teaser": pack.SunTeaser, "score": pack.Scores["sun"]},
|
||||
{"key": "moon", "title": "月亮节奏", "teaser": fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), "score": pack.Scores["moon"]},
|
||||
{"key": "rise", "title": "上升表达", "teaser": fmt.Sprintf("第一印象更偏「%s」", rise.Sign), "score": pack.Scores["rise"]},
|
||||
{"key": "relation", "title": "关系互动", "teaser": pack.RelationTeaser, "score": pack.Scores["relation"]},
|
||||
{"key": "career", "title": "事业节奏", "teaser": pack.CareerTeaser, "score": pack.Scores["career"]},
|
||||
{"key": "growth", "title": "成长方向", "teaser": pack.GrowthTeaser, "score": pack.Scores["growth"]},
|
||||
}
|
||||
|
||||
asOf := opts.AsOf
|
||||
if asOf.IsZero() {
|
||||
asOf = time.Now()
|
||||
}
|
||||
fort := fortune.Build(chart, asOf)
|
||||
daily := fort.Daily
|
||||
|
||||
planetsOut := make([]map[string]any, 0, len(chart.Planets))
|
||||
for _, p := range chart.Planets {
|
||||
planetsOut = append(planetsOut, map[string]any{
|
||||
"key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey,
|
||||
"degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House,
|
||||
"element": p.Element, "modality": p.Modality, "lon": p.Lon,
|
||||
})
|
||||
}
|
||||
housesOut := make([]map[string]any, 0, len(chart.Houses))
|
||||
for _, h := range chart.Houses {
|
||||
// Whole-sign house cusp ≈ asc sign start + (n-1)*30
|
||||
cusp := float64((signIndexOf(h.Key)) * 30)
|
||||
housesOut = append(housesOut, map[string]any{
|
||||
"num": h.Num, "sign": h.Sign, "sign_key": h.Key, "cusp_lon": cusp,
|
||||
})
|
||||
}
|
||||
|
||||
aspects := natal.Aspects(chart)
|
||||
aspectMaps := natal.AspectsAsMaps(aspects)
|
||||
previewN := min(4, len(aspectMaps))
|
||||
aspectPreview := aspectMaps[:previewN]
|
||||
|
||||
summary := map[string]any{
|
||||
"title": "星座·基础",
|
||||
"style_label": pack.Label,
|
||||
"headline": fmt.Sprintf("%s的星座更偏「%s」", name, pack.Label),
|
||||
"one_liner": pack.OneLiner,
|
||||
"overview": fmt.Sprintf("%s%s(太阳:%s;月亮:%s;上升:%s)。", name, pack.Overview, sun.Sign, moon.Sign, rise.Sign),
|
||||
"life_tip": pack.LifeTip,
|
||||
"keywords": pack.Keywords,
|
||||
"dimensions": dims,
|
||||
"sign_cards": signCards,
|
||||
"sun_sign": sun.Sign,
|
||||
"moon_sign": moon.Sign,
|
||||
"rise_sign": rise.Sign,
|
||||
"chart": map[string]any{
|
||||
"note": chart.Note, "place": chart.PlaceLabel,
|
||||
"has_time": chart.HasTime, "has_place": chart.HasPlace,
|
||||
"lat": chart.Lat, "lng": chart.Lng,
|
||||
"asc_lon": rise.Lon, "houses": housesOut,
|
||||
},
|
||||
"planets": planetsOut,
|
||||
"aspects_preview": aspectPreview,
|
||||
"fortune": fort.AsMap(),
|
||||
"transits": fort.AsMap()["transits"],
|
||||
"daily_soft": map[string]any{
|
||||
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
|
||||
"energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label,
|
||||
},
|
||||
"strengths_preview": pack.Strengths[:min(3, len(pack.Strengths))],
|
||||
"blind_spots_preview": []string{"完整相位与年运详解见深度版。"},
|
||||
"interaction_tags": []string{
|
||||
fmt.Sprintf("太阳·%s", sun.Sign),
|
||||
fmt.Sprintf("月亮·%s", moon.Sign),
|
||||
fmt.Sprintf("上升·%s", rise.Sign),
|
||||
pack.Label,
|
||||
},
|
||||
}
|
||||
|
||||
aspectBullets := make([]string, 0, min(8, len(aspects)))
|
||||
for i, a := range aspects {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
aspectBullets = append(aspectBullets, a.Label)
|
||||
}
|
||||
|
||||
detail := map[string]any{
|
||||
"title": "星座·完整分析",
|
||||
"aspects": aspectMaps,
|
||||
"sections": []map[string]any{
|
||||
section("太阳星座 · "+sun.Sign, pack.SunDeep, pack.SunBullets),
|
||||
section("月亮星座 · "+moon.Sign, fmt.Sprintf("情绪底色带有「%s」特质:%s", moon.Sign, moonPack.MoonDeep), moonPack.MoonBullets),
|
||||
section("上升星座 · "+rise.Sign, fmt.Sprintf("对外第一印象偏「%s」:%s", rise.Sign, risePack.RiseDeep), risePack.RiseBullets),
|
||||
section("行星相位要点", "主要相位帮助理解行动、情感与责任节奏之间的张力与互补。", aspectBullets),
|
||||
section("行星落座", "落座与宫位是日常节奏的参照。", planetBullets(chart)),
|
||||
section("年运详解", fort.Yearly.Tip+" "+fort.Yearly.Caution, []string{
|
||||
fmt.Sprintf("综合分 %d(%s)", fort.Yearly.Score, fort.Yearly.Label),
|
||||
fmt.Sprintf("感情 %d · 事业 %d · 财务 %d · 心情 %d", fort.Yearly.Dims["love"], fort.Yearly.Dims["career"], fort.Yearly.Dims["money"], fort.Yearly.Dims["mood"]),
|
||||
fort.Yearly.Lucky,
|
||||
}),
|
||||
section("一生运势", fort.Lifetime.Tip, []string{fort.Lifetime.Caution, fort.Lifetime.Lucky}),
|
||||
section("关系互动", pack.RelationDeep, pack.RelationBullets),
|
||||
section("事业与学习节奏", pack.CareerDeep, pack.CareerBullets),
|
||||
section("成长方向", pack.GrowthDeep, pack.GrowthBullets),
|
||||
},
|
||||
"strengths": pack.Strengths,
|
||||
"blind_spots": pack.BlindSpots,
|
||||
"growth_plan": []map[string]any{
|
||||
{"phase": "本周", "focus": pack.PlanWeek},
|
||||
{"phase": "本月", "focus": pack.PlanMonth},
|
||||
{"phase": "长期", "focus": pack.PlanLong},
|
||||
},
|
||||
"conversation_scripts": pack.Scripts,
|
||||
"faq": pack.FAQ,
|
||||
"behavior_pattern": pack.SunDeep,
|
||||
"relation_style": pack.RelationDeep,
|
||||
"growth_direction": pack.GrowthDeep,
|
||||
"fortune_detail": fort.AsMap(),
|
||||
}
|
||||
return Output{Summary: summary, Detail: detail}, nil
|
||||
}
|
||||
|
||||
func signIndexOf(key string) int {
|
||||
for i, s := range []string{
|
||||
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
|
||||
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
|
||||
} {
|
||||
if s == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func planetBullets(chart natal.Chart) []string {
|
||||
out := make([]string, 0, 6)
|
||||
for _, key := range []string{"mercury", "venus", "mars", "jupiter", "saturn"} {
|
||||
for _, p := range chart.Planets {
|
||||
if p.Key == key {
|
||||
out = append(out, fmt.Sprintf("%s在%s第%d宫", p.Title, p.Sign, p.House))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func signCard(key, title string, b natal.Body, teaser string, keywords []string) map[string]any {
|
||||
ks := keywords
|
||||
if len(ks) > 3 {
|
||||
ks = ks[:3]
|
||||
}
|
||||
return map[string]any{
|
||||
"key": key, "title": title, "label": b.Sign,
|
||||
"element": b.Element, "modality": b.Modality,
|
||||
"teaser": teaser, "keywords": ks,
|
||||
}
|
||||
}
|
||||
|
||||
func section(title, body string, bullets []string) map[string]any {
|
||||
return map[string]any{"title": title, "body": body, "bullets": bullets}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SignLabels returns sun/moon/rise labels (uses natal engine).
|
||||
func SignLabels(birth time.Time, birthTime *string) (sun, moon, rise string) {
|
||||
return SignLabelsPlace(birth, birthTime, nil)
|
||||
}
|
||||
|
||||
// SignLabelsPlace includes birth place for rising accuracy.
|
||||
func SignLabelsPlace(birth time.Time, birthTime, place *string) (sun, moon, rise string) {
|
||||
c, err := natal.Compute(birth, birthTime, place)
|
||||
if err != nil {
|
||||
return "", "", ""
|
||||
}
|
||||
return c.Sun.Sign, c.Moon.Sign, c.Rise.Sign
|
||||
}
|
||||
|
||||
// NatalChart exposes chart for relation/synastry.
|
||||
func NatalChart(birth time.Time, birthTime, place *string) (natal.Chart, error) {
|
||||
return natal.Compute(birth, birthTime, place)
|
||||
}
|
||||
|
||||
type signMeta struct {
|
||||
Key, Label, Element, Modality string
|
||||
}
|
||||
|
||||
type pack struct {
|
||||
Label, OneLiner, Overview, LifeTip string
|
||||
Keywords []string
|
||||
Scores map[string]int
|
||||
SunTeaser, RelationTeaser, CareerTeaser, GrowthTeaser string
|
||||
SunDeep, MoonDeep, RiseDeep, RelationDeep, CareerDeep, GrowthDeep string
|
||||
SunBullets, MoonBullets, RiseBullets, RelationBullets, CareerBullets, GrowthBullets []string
|
||||
Strengths, BlindSpots, Scripts []string
|
||||
PlanWeek, PlanMonth, PlanLong string
|
||||
FAQ []map[string]string
|
||||
}
|
||||
|
||||
var packs = map[string]pack{}
|
||||
|
||||
func init() {
|
||||
for _, s := range []signMeta{
|
||||
{"aries", "白羊", "火", "开创"}, {"taurus", "金牛", "土", "固定"}, {"gemini", "双子", "风", "变动"},
|
||||
{"cancer", "巨蟹", "水", "开创"}, {"leo", "狮子", "火", "固定"}, {"virgo", "处女", "土", "变动"},
|
||||
{"libra", "天秤", "风", "开创"}, {"scorpio", "天蝎", "水", "固定"}, {"sagittarius", "射手", "火", "变动"},
|
||||
{"capricorn", "摩羯", "土", "开创"}, {"aquarius", "水瓶", "风", "固定"}, {"pisces", "双鱼", "水", "变动"},
|
||||
} {
|
||||
packs[s.Key] = defaultPack(s)
|
||||
}
|
||||
packs["aries"] = enrich(packs["aries"], "开创行动者", "先动起来,再在行动里想清楚。",
|
||||
"你容易被新目标点燃,讨厌拖沓。优势是启动快;需要留意的是收尾与倾听。")
|
||||
packs["taurus"] = enrich(packs["taurus"], "稳健沉淀者", "你重视踏实与感官舒适,变化太快会消耗你。",
|
||||
"你擅长把事情做稳做久。关系与工作里都需要可预期的节奏。")
|
||||
packs["gemini"] = enrich(packs["gemini"], "灵活连接者", "你靠好奇与对话充电,也容易分心。",
|
||||
"信息与交流是你的养分。把想法收成一个可交付的小闭环,会更有成就感。")
|
||||
packs["cancer"] = enrich(packs["cancer"], "细腻守护者", "你对情绪与归属很敏感,安全比热闹更重要。",
|
||||
"你擅长照顾氛围与关系。记得也把自己的需要说清楚,而不是只默默撑着。")
|
||||
packs["leo"] = enrich(packs["leo"], "热烈表达者", "你需要被看见,也愿意照亮别人。",
|
||||
"热情与表达是你的名片。把认可需求说成具体请求,关系会更顺。")
|
||||
packs["virgo"] = enrich(packs["virgo"], "细致完善者", "你看见细节与改进空间,也容易自我要求过高。",
|
||||
"把「足够好」纳入标准,你会轻松很多,交付也会更快。")
|
||||
packs["libra"] = enrich(packs["libra"], "平衡协调者", "你追求公平与和谐,有时会为难自己。",
|
||||
"协调是天赋。重要决定里请给自己一票,而不只是各方折中。")
|
||||
packs["scorpio"] = enrich(packs["scorpio"], "深潜洞察者", "你看重真诚与深度,讨厌浮于表面。",
|
||||
"信任慢、一旦建立则很深。练习用语言同步感受,减少猜疑消耗。")
|
||||
packs["sagittarius"] = enrich(packs["sagittarius"], "开阔探索者", "你需要视野与意义,讨厌被框死。",
|
||||
"探索欲强。给自由一点结构,热情才能变成持续作品。")
|
||||
packs["capricorn"] = enrich(packs["capricorn"], "负责攀登者", "你看长远目标,愿意为结果负责。",
|
||||
"担当是优势。学会求助与休息,攀登才可持续。")
|
||||
packs["aquarius"] = enrich(packs["aquarius"], "独特思考者", "你重视独立与新意,也需要被理解。",
|
||||
"独特视角是礼物。把想法翻译成别人跟得上的一步行动。")
|
||||
packs["pisces"] = enrich(packs["pisces"], "共感想象者", "你感受力强,边界容易被情绪浪潮冲开。",
|
||||
"共情是天赋。区分「理解」与「承包」,你会更稳。")
|
||||
}
|
||||
|
||||
func defaultPack(s signMeta) pack {
|
||||
return pack{
|
||||
Label: s.Label + "风格探索者", OneLiner: "用星座认识自己的节奏与运势起伏。",
|
||||
Overview: "在星座框架里,你的表达与需求有独特侧重。",
|
||||
LifeTip: "本周选一件小事完整做完,并告诉亲近的人你的真实需要。",
|
||||
Keywords: []string{s.Label, s.Element + "象", s.Modality, "星座"},
|
||||
Scores: map[string]int{"sun": 78, "moon": 70, "rise": 72, "relation": 74, "career": 73, "growth": 75},
|
||||
SunTeaser: "核心驱动力清晰,行动有自己的节拍。", RelationTeaser: "关系里需要被理解与尊重节奏。",
|
||||
CareerTeaser: "适合发挥你风格优势的场景。", GrowthTeaser: "下一步是看见盲区并小步调整。",
|
||||
SunDeep: "太阳星座描述你的核心动机与自我表达。",
|
||||
MoonDeep: "月亮星座指向情绪调节与安全感来源。",
|
||||
RiseDeep: "上升星座影响别人对你的第一印象。",
|
||||
RelationDeep: "关系中把需求说具体,比期待对方「应该懂」更有效。",
|
||||
CareerDeep: "工作上优先发挥你的风格优势,并用小里程碑对抗拖延。",
|
||||
GrowthDeep: "成长是认识模式后做可验证的小调整。",
|
||||
SunBullets: []string{"核心动机可被命名", "表达有风格偏好", "适合自我探索"},
|
||||
MoonBullets: []string{"情绪需要出口", "安全感来源因人而异", "独处或连接可充电"},
|
||||
RiseBullets: []string{"第一印象可调节", "外显≠全部自我", "可练习温和表达"},
|
||||
RelationBullets: []string{"需求具体化", "尊重双方节奏", "冲突先复述再方案"},
|
||||
CareerBullets: []string{"发挥风格优势", "小步交付", "复盘节奏"},
|
||||
GrowthBullets: []string{"看见模式", "小步验证", "结合运势调整"},
|
||||
Strengths: []string{"风格清晰", "可探索性强", "利于自我对话"},
|
||||
BlindSpots: []string{"标签固化", "忽略情境差异", "过度解读"},
|
||||
Scripts: []string{"我想先说清我的节奏,再听你的。", "我不是冷淡,我需要一点整理时间。", "我们共同目标是……,下一步只定一件事。"},
|
||||
PlanWeek: "用三句话写下:我的优势 / 我的消耗点 / 我本周要试的一小步。",
|
||||
PlanMonth: "在关系或工作中练习两次「先复述对方,再提需要」。",
|
||||
PlanLong: "建立个人节奏手册:什么充电、什么耗电、如何请求支持。",
|
||||
FAQ: []map[string]string{
|
||||
{"q": "运势是预测吗?", "a": "运势分与建议帮助你调整节奏与决策,请结合现实判断,勿作唯一依据。"},
|
||||
{"q": "星盘准吗?", "a": "出生时与出生地越完整,上升与宫位越贴近;算法为可复现近似星历。"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func enrich(base pack, label, one, overview string) pack {
|
||||
base.Label = label
|
||||
base.OneLiner = one
|
||||
base.Overview = overview
|
||||
base.Keywords = []string{label, "星座", "运势"}
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package star
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildDeterministic(t *testing.T) {
|
||||
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
|
||||
asOf := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
opts := BuildOpts{Birth: birth, Name: "小愈", AsOf: asOf}
|
||||
a, err := BuildWith(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := BuildWith(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Summary["headline"] != b.Summary["headline"] {
|
||||
t.Fatal("not deterministic")
|
||||
}
|
||||
if a.Summary["sun_sign"] == nil || a.Detail["sections"] == nil {
|
||||
t.Fatal("missing fields")
|
||||
}
|
||||
if a.Summary["sign_cards"] == nil || a.Summary["fortune"] == nil || a.Summary["planets"] == nil {
|
||||
t.Fatal("missing sign_cards, fortune or planets")
|
||||
}
|
||||
if a.Summary["aspects_preview"] == nil {
|
||||
t.Fatal("missing aspects_preview")
|
||||
}
|
||||
chart, _ := a.Summary["chart"].(map[string]any)
|
||||
if chart["asc_lon"] == nil {
|
||||
t.Fatal("missing chart.asc_lon")
|
||||
}
|
||||
if a.Detail["aspects"] == nil {
|
||||
t.Fatal("missing detail.aspects")
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
blob := string(raw)
|
||||
for _, bad := range []string{"算命"} {
|
||||
if strings.Contains(blob, bad) {
|
||||
t.Fatalf("forbidden %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWithBirthTimeChangesRise(t *testing.T) {
|
||||
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
|
||||
bt := "14:30"
|
||||
place := "北京"
|
||||
a, err := BuildWith(BuildOpts{Birth: birth, Name: "我"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := BuildWith(BuildOpts{Birth: birth, BirthTime: &bt, BirthPlace: &place, Name: "我"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Summary["planets"] == nil || b.Summary["planets"] == nil {
|
||||
t.Fatal("missing planets")
|
||||
}
|
||||
_ = a.Summary["rise_sign"]
|
||||
_ = b.Summary["rise_sign"]
|
||||
}
|
||||
|
||||
func TestBuildFortuneScores(t *testing.T) {
|
||||
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
|
||||
out, err := BuildWith(BuildOpts{Birth: birth, Name: "测", AsOf: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fort, ok := out.Summary["fortune"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("fortune missing")
|
||||
}
|
||||
daily, ok := fort["daily"].(map[string]any)
|
||||
if !ok || daily["score"] == nil {
|
||||
t.Fatal("daily score missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Package ephemeris computes tropical ecliptic longitudes.
|
||||
// Default backend: Swiss Ephemeris Moshier (no external ephe files).
|
||||
// NOTE: go-swisseph is AGPL-3.0; commercial deployment needs Astrodienst SE license or AGPL compliance.
|
||||
package ephemeris
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
swe "github.com/tejzpr/go-swisseph"
|
||||
)
|
||||
|
||||
// Body keys match natal chart keys.
|
||||
const (
|
||||
BodySun = "sun"
|
||||
BodyMoon = "moon"
|
||||
BodyMercury = "mercury"
|
||||
BodyVenus = "venus"
|
||||
BodyMars = "mars"
|
||||
BodyJupiter = "jupiter"
|
||||
BodySaturn = "saturn"
|
||||
BodyUranus = "uranus"
|
||||
BodyNeptune = "neptune"
|
||||
BodyPluto = "pluto"
|
||||
)
|
||||
|
||||
var bodyID = map[string]int32{
|
||||
BodySun: swe.Sun,
|
||||
BodyMoon: swe.Moon,
|
||||
BodyMercury: swe.Mercury,
|
||||
BodyVenus: swe.Venus,
|
||||
BodyMars: swe.Mars,
|
||||
BodyJupiter: swe.Jupiter,
|
||||
BodySaturn: swe.Saturn,
|
||||
BodyUranus: swe.Uranus,
|
||||
BodyNeptune: swe.Neptune,
|
||||
BodyPluto: swe.Pluto,
|
||||
}
|
||||
|
||||
// PlanetOrder is the standard body sequence (excluding ASC).
|
||||
var PlanetOrder = []string{
|
||||
BodySun, BodyMoon, BodyMercury, BodyVenus, BodyMars,
|
||||
BodyJupiter, BodySaturn, BodyUranus, BodyNeptune, BodyPluto,
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
iflag int32 = swe.FlagMoseph
|
||||
epheOK bool
|
||||
)
|
||||
|
||||
func initSE() {
|
||||
once.Do(func() {
|
||||
path := os.Getenv("SE_EPHE_PATH")
|
||||
if path == "" {
|
||||
path = os.Getenv("EPHEMERIS_PATH")
|
||||
}
|
||||
if path != "" {
|
||||
swe.SetEphePath(path)
|
||||
iflag = swe.FlagSwieph
|
||||
epheOK = true
|
||||
} else {
|
||||
// Moshier: no files required
|
||||
iflag = swe.FlagMoseph
|
||||
epheOK = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Backend returns "moshier" or "swiss" for diagnostics.
|
||||
func Backend() string {
|
||||
initSE()
|
||||
if iflag == swe.FlagSwieph {
|
||||
return "swiss"
|
||||
}
|
||||
return "moshier"
|
||||
}
|
||||
|
||||
// JulianDayUT converts a UTC instant to Julian Day (UT).
|
||||
func JulianDayUT(utc time.Time) float64 {
|
||||
initSE()
|
||||
utc = utc.UTC()
|
||||
sec := float64(utc.Second()) + float64(utc.Nanosecond())/1e9
|
||||
dret, err := swe.UtcToJd(
|
||||
int32(utc.Year()), int32(utc.Month()), int32(utc.Day()),
|
||||
int32(utc.Hour()), int32(utc.Minute()), sec, swe.GregCal,
|
||||
)
|
||||
if err != nil {
|
||||
// Fallback Meeus-style JD
|
||||
return julianDayFallback(utc)
|
||||
}
|
||||
return dret[1] // UT
|
||||
}
|
||||
|
||||
// PlanetLon returns tropical ecliptic longitude in degrees [0,360).
|
||||
func PlanetLon(jdUt float64, body string) (float64, error) {
|
||||
initSE()
|
||||
ipl, ok := bodyID[body]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown body %q", body)
|
||||
}
|
||||
r := swe.CalcUT(jdUt, ipl, iflag)
|
||||
if r.Flag < 0 {
|
||||
return 0, fmt.Errorf("swe calc %s: %s", body, r.Error)
|
||||
}
|
||||
return norm360(r.Data[0]), nil
|
||||
}
|
||||
|
||||
// Ascendant returns tropical ASC longitude degrees using Placidus cusps (Points[0]).
|
||||
// Callers apply Whole Sign houses from this ASC.
|
||||
func Ascendant(jdUt, lat, lng float64) (float64, error) {
|
||||
initSE()
|
||||
h := swe.Houses(jdUt, lat, lng, 'P')
|
||||
if h.Flag < 0 || len(h.Points) < 1 {
|
||||
return 0, fmt.Errorf("swe houses failed flag=%d", h.Flag)
|
||||
}
|
||||
return norm360(h.Points[0]), nil
|
||||
}
|
||||
|
||||
// AllPlanetLons returns longitudes for PlanetOrder.
|
||||
func AllPlanetLons(jdUt float64) (map[string]float64, error) {
|
||||
out := make(map[string]float64, len(PlanetOrder))
|
||||
for _, k := range PlanetOrder {
|
||||
lon, err := PlanetLon(jdUt, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = lon
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func norm360(x float64) float64 {
|
||||
x = math.Mod(x, 360)
|
||||
if x < 0 {
|
||||
x += 360
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func julianDayFallback(t time.Time) float64 {
|
||||
y := t.Year()
|
||||
m := int(t.Month())
|
||||
d := float64(t.Day()) + float64(t.Hour())/24 + float64(t.Minute())/1440 + float64(t.Second())/86400
|
||||
if m <= 2 {
|
||||
y--
|
||||
m += 12
|
||||
}
|
||||
A := y / 100
|
||||
B := 2 - A + A/4
|
||||
return math.Floor(365.25*float64(y+4716)) + math.Floor(30.6001*float64(m+1)) + d + float64(B) - 1524.5
|
||||
}
|
||||
|
||||
// Ready reports whether ephemeris init succeeded (always true for Moshier).
|
||||
func Ready() bool {
|
||||
initSE()
|
||||
return epheOK
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ephemeris
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSunMay1990(t *testing.T) {
|
||||
if !Ready() {
|
||||
t.Fatal("ephemeris not ready")
|
||||
}
|
||||
// 1990-05-12 10:30 CST = 02:30 UTC
|
||||
utc := time.Date(1990, 5, 12, 2, 30, 0, 0, time.UTC)
|
||||
jd := JulianDayUT(utc)
|
||||
lon, err := PlanetLon(jd, BodySun)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ~51° Taurus
|
||||
if lon < 48 || lon > 55 {
|
||||
t.Fatalf("sun lon=%.2f want ~51°", lon)
|
||||
}
|
||||
asc, err := Ascendant(jd, 31.23, 121.47)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asc < 0 || asc >= 360 {
|
||||
t.Fatalf("bad asc %.2f", asc)
|
||||
}
|
||||
if Backend() == "" {
|
||||
t.Fatal("empty backend")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllPlanetLons(t *testing.T) {
|
||||
jd := JulianDayUT(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
m, err := AllPlanetLons(jd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m) != len(PlanetOrder) {
|
||||
t.Fatalf("got %d", len(m))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Package fortune synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
|
||||
package fortune
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
// Period is one fortune block.
|
||||
type Period struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Score int `json:"score"`
|
||||
Label string `json:"label"`
|
||||
Dims map[string]int `json:"dims"`
|
||||
Tip string `json:"tip"`
|
||||
Lucky string `json:"lucky"`
|
||||
Caution string `json:"caution"`
|
||||
Focus string `json:"focus"`
|
||||
}
|
||||
|
||||
// Transit is a simplified day transit tip relative to natal.
|
||||
type Transit struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Aspect string `json:"aspect"`
|
||||
Tip string `json:"tip"`
|
||||
}
|
||||
|
||||
// Bundle holds all periods plus lifetime and transits.
|
||||
type Bundle struct {
|
||||
Daily Period `json:"daily"`
|
||||
Weekly Period `json:"weekly"`
|
||||
Monthly Period `json:"monthly"`
|
||||
Yearly Period `json:"yearly"`
|
||||
Lifetime Period `json:"lifetime"`
|
||||
Transits []Transit `json:"transits"`
|
||||
}
|
||||
|
||||
// Build returns fortune for natal chart as of asOf (date matters).
|
||||
func Build(chart natal.Chart, asOf time.Time) Bundle {
|
||||
if asOf.IsZero() {
|
||||
asOf = time.Now()
|
||||
}
|
||||
sun := chart.Sun.Lon
|
||||
moon := chart.Moon.Lon
|
||||
daySeed := asOf.Year()*1000 + asOf.YearDay() + int(sun) + int(moon)
|
||||
|
||||
daily := period("daily", "今日运势", daySeed, sun, moon, chart,
|
||||
[]string{"行动", "沟通", "情绪", "财运", "桃花"},
|
||||
[]string{
|
||||
fmt.Sprintf("发挥太阳「%s」的主动性,先完成一件小事。", chart.Sun.Sign),
|
||||
fmt.Sprintf("照顾月亮「%s」的情绪需要,给自己缓冲。", chart.Moon.Sign),
|
||||
"适合推进沟通:先复述再提方案。",
|
||||
"宜整理待办与开销,给节奏一点秩序。",
|
||||
"轻社交或独处充电均可,按电量选择。",
|
||||
})
|
||||
|
||||
weekSeed := asOf.Year()*100 + isoWeek(asOf) + int(sun)/3
|
||||
weekly := period("weekly", "本周运势", weekSeed, sun, moon, chart,
|
||||
[]string{"事业", "关系", "学习", "休息", "决策"},
|
||||
[]string{
|
||||
"本周适合定一个可交付的小目标并公开承诺。",
|
||||
"关系上主动约一次轻松同步,不谈对错。",
|
||||
"学习/复盘:把灵感写成三条行动。",
|
||||
"安排半日空档恢复精力。",
|
||||
"重大决定用「可逆/不可逆」分类后再选速度。",
|
||||
})
|
||||
|
||||
monthSeed := asOf.Year()*12 + int(asOf.Month()) + int(moon)/5
|
||||
monthly := period("monthly", "本月运势", monthSeed, sun, moon, chart,
|
||||
[]string{"感情", "事业", "财务", "健康节奏", "人际"},
|
||||
[]string{
|
||||
fmt.Sprintf("本月主题贴近「%s」:把热情落成节奏。", chart.Sun.Sign),
|
||||
"感情上说清需要,比猜测更有效。",
|
||||
"财务宜做一次月度复盘,砍掉低价值开销。",
|
||||
"作息与运动选可持续的小剂量。",
|
||||
"拓展一个人际弱连接,可能带来信息增益。",
|
||||
})
|
||||
|
||||
yearSeed := asOf.Year() + int(sun) + int(chart.Rise.Lon)/10
|
||||
yearly := period("yearly", "今年运势", yearSeed, sun, moon, chart,
|
||||
[]string{"成长主线", "关系课题", "事业方向", "财富节奏", "身心"},
|
||||
[]string{
|
||||
fmt.Sprintf("今年宜强化太阳「%s」优势,并补月亮「%s」的安全感建设。", chart.Sun.Sign, chart.Moon.Sign),
|
||||
"关系课题:边界与亲密并重,写成说明书。",
|
||||
"事业上选择能看到里程碑的路径。",
|
||||
"财富:先稳现金流,再谈进取配置。",
|
||||
"身心:季度体检式复盘情绪与睡眠。",
|
||||
})
|
||||
|
||||
life := lifetime(chart)
|
||||
tr := transits(chart, asOf)
|
||||
|
||||
return Bundle{
|
||||
Daily: daily, Weekly: weekly, Monthly: monthly, Yearly: yearly,
|
||||
Lifetime: life, Transits: tr,
|
||||
}
|
||||
}
|
||||
|
||||
func lifetime(chart natal.Chart) Period {
|
||||
sat := bodyLon(chart, "saturn")
|
||||
seed := int(chart.Sun.Lon) + int(sat)/2 + int(chart.Moon.Lon)/3
|
||||
score := 62 + seed%28
|
||||
stages := []string{
|
||||
fmt.Sprintf("成长主线贴近太阳「%s」:用可见行动定义自我。", chart.Sun.Sign),
|
||||
fmt.Sprintf("情感底色来自月亮「%s」:安全感与表达节奏需要长期经营。", chart.Moon.Sign),
|
||||
fmt.Sprintf("对外姿态偏上升「%s」:第一印象可以主动校准。", chart.Rise.Sign),
|
||||
}
|
||||
tip := stages[seed%len(stages)] + " 人生阶段不必一次做完,按十年课题拆解更稳。"
|
||||
return Period{
|
||||
Key: "lifetime", Title: "一生运势摘要", Score: score, Label: scoreLabel(score),
|
||||
Dims: map[string]int{
|
||||
"love": 50 + seed%40, "career": 52 + (seed*3)%40,
|
||||
"money": 48 + (seed*5)%42, "mood": 55 + (seed*7)%35,
|
||||
},
|
||||
Tip: tip, Focus: "人生阶段",
|
||||
Lucky: "长期复盘 · 边界清晰",
|
||||
Caution: "避免把阶段标签当成宿命;可调整节奏与选择。",
|
||||
}
|
||||
}
|
||||
|
||||
func transits(chart natal.Chart, asOf time.Time) []Transit {
|
||||
// Approximate "transit" sun/moon using same ephemeris at asOf noon CST.
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
local := time.Date(asOf.Year(), asOf.Month(), asOf.Day(), 12, 0, 0, 0, loc)
|
||||
tChart, err := natal.Compute(local, strPtr("12:00"), placePtr(chart.PlaceLabel))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tSun, tMoon := tChart.Sun.Lon, tChart.Moon.Lon
|
||||
|
||||
out := []Transit{}
|
||||
out = append(out, transitHit("transit_sun_natal_sun", "行运太阳×本命太阳", tSun, chart.Sun.Lon,
|
||||
"适合主动推进与自我表达相关的事。",
|
||||
"宜复盘目标,不急着扩张战线。",
|
||||
"注意沟通语气,先对齐再行动。"))
|
||||
out = append(out, transitHit("transit_moon_natal_moon", "行运月亮×本命月亮", tMoon, chart.Moon.Lon,
|
||||
"情绪敏感日,给自己更多缓冲。",
|
||||
"适合温柔连结与休息充电。",
|
||||
"避免情绪化决策,先写下来再说。"))
|
||||
out = append(out, transitHit("transit_sun_natal_moon", "行运太阳×本命月亮", tSun, chart.Moon.Lon,
|
||||
"外在节奏触动内在需求,适合说清感受。",
|
||||
"工作与情绪平衡日,留一点空白。",
|
||||
"别硬撑社交,按电量选择场合。"))
|
||||
return out
|
||||
}
|
||||
|
||||
func transitHit(key, title string, a, b float64, conj, soft, hard string) Transit {
|
||||
diff := natal.AngleDiff(a, b)
|
||||
aspect, tip := "弱互动", "节奏平常,按计划推进即可。"
|
||||
switch {
|
||||
case near(diff, 0, 8):
|
||||
aspect, tip = "合相", conj
|
||||
case near(diff, 60, 6) || near(diff, 120, 7):
|
||||
aspect, tip = "和谐相位", soft
|
||||
case near(diff, 90, 7) || near(diff, 180, 8):
|
||||
aspect, tip = "张力相位", hard
|
||||
}
|
||||
return Transit{Key: key, Title: title, Aspect: aspect, Tip: tip}
|
||||
}
|
||||
|
||||
func near(diff, target, orb float64) bool {
|
||||
return math.Abs(diff-target) <= orb
|
||||
}
|
||||
|
||||
func bodyLon(c natal.Chart, key string) float64 {
|
||||
for _, p := range c.Planets {
|
||||
if p.Key == key {
|
||||
return p.Lon
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
func placePtr(s string) *string {
|
||||
if s == "" || s == "默认(未填出生地)" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func period(key, title string, seed int, sun, moon float64, chart natal.Chart, focuses, tips []string) Period {
|
||||
score := 55 + (seed*7+int(sun)+int(moon))%41 // 55–95
|
||||
love := 50 + (seed*3+int(moon))%46
|
||||
career := 50 + (seed*5+int(sun))%46
|
||||
money := 48 + (seed*11+int(chart.Rise.Lon))%47
|
||||
mood := 52 + (seed*13+int(moon)/2)%45
|
||||
i := seed % len(tips)
|
||||
return Period{
|
||||
Key: key, Title: title, Score: score, Label: scoreLabel(score),
|
||||
Dims: map[string]int{
|
||||
"love": love, "career": career, "money": money, "mood": mood,
|
||||
},
|
||||
Tip: tips[i], Focus: focuses[i%len(focuses)],
|
||||
Lucky: luckyFrom(seed),
|
||||
Caution: cautionFrom(seed, chart),
|
||||
}
|
||||
}
|
||||
|
||||
func scoreLabel(s int) string {
|
||||
switch {
|
||||
case s >= 85:
|
||||
return "大吉"
|
||||
case s >= 75:
|
||||
return "吉"
|
||||
case s >= 65:
|
||||
return "中平偏吉"
|
||||
case s >= 55:
|
||||
return "平稳"
|
||||
default:
|
||||
return "需谨慎"
|
||||
}
|
||||
}
|
||||
|
||||
func luckyFrom(seed int) string {
|
||||
colors := []string{"红色", "金色", "蓝色", "绿色", "紫色", "白色"}
|
||||
nums := []string{"3", "6", "7", "8", "9"}
|
||||
return colors[seed%len(colors)] + " · 数字 " + nums[seed%len(nums)]
|
||||
}
|
||||
|
||||
func cautionFrom(seed int, chart natal.Chart) string {
|
||||
cautions := []string{
|
||||
"避免冲动承诺,尤其财务相关。",
|
||||
fmt.Sprintf("「%s」能量过强时易急躁,先深呼吸再回复。", chart.Sun.Sign),
|
||||
"少开多线任务,完成比完美重要。",
|
||||
"情绪波动时先写下来,再找人聊。",
|
||||
}
|
||||
return cautions[seed%len(cautions)]
|
||||
}
|
||||
|
||||
func isoWeek(t time.Time) int {
|
||||
_, w := t.ISOWeek()
|
||||
return w
|
||||
}
|
||||
|
||||
// AsMap for JSON embedding.
|
||||
func (b Bundle) AsMap() map[string]any {
|
||||
tr := make([]map[string]any, 0, len(b.Transits))
|
||||
for _, t := range b.Transits {
|
||||
tr = append(tr, map[string]any{
|
||||
"key": t.Key, "title": t.Title, "aspect": t.Aspect, "tip": t.Tip,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"daily": periodMap(b.Daily), "weekly": periodMap(b.Weekly),
|
||||
"monthly": periodMap(b.Monthly), "yearly": periodMap(b.Yearly),
|
||||
"lifetime": periodMap(b.Lifetime), "transits": tr,
|
||||
}
|
||||
}
|
||||
|
||||
func periodMap(p Period) map[string]any {
|
||||
return map[string]any{
|
||||
"key": p.Key, "title": p.Title, "score": p.Score, "label": p.Label,
|
||||
"dims": p.Dims, "tip": p.Tip, "lucky": p.Lucky, "caution": p.Caution, "focus": p.Focus,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package fortune
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
func TestBuildPeriods(t *testing.T) {
|
||||
chart, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC)
|
||||
a := Build(chart, asOf)
|
||||
b := Build(chart, asOf)
|
||||
if a.Daily.Score != b.Daily.Score || a.Yearly.Tip != b.Yearly.Tip {
|
||||
t.Fatal("not deterministic")
|
||||
}
|
||||
if a.Daily.Score < 50 || a.Weekly.Dims["love"] == 0 {
|
||||
t.Fatalf("bad scores %+v", a.Daily)
|
||||
}
|
||||
m := a.AsMap()
|
||||
if m["monthly"] == nil || m["lifetime"] == nil {
|
||||
t.Fatal("missing monthly or lifetime")
|
||||
}
|
||||
tr, ok := m["transits"].([]map[string]any)
|
||||
if !ok || len(tr) == 0 {
|
||||
t.Fatalf("transits missing: %#v", m["transits"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package natal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Aspect is a major aspect between two chart points.
|
||||
type Aspect struct {
|
||||
A string `json:"a"`
|
||||
B string `json:"b"`
|
||||
ATitle string `json:"a_title"`
|
||||
BTitle string `json:"b_title"`
|
||||
Type string `json:"type"` // conjunction|sextile|square|trine|opposition
|
||||
Angle float64 `json:"angle"`
|
||||
Orb float64 `json:"orb"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
var aspectDefs = []struct {
|
||||
Type string
|
||||
Angle float64
|
||||
Orb float64
|
||||
Label string
|
||||
}{
|
||||
{"conjunction", 0, 8, "合相"},
|
||||
{"sextile", 60, 6, "六合"},
|
||||
{"square", 90, 7, "刑相"},
|
||||
{"trine", 120, 7, "拱相"},
|
||||
{"opposition", 180, 8, "对冲"},
|
||||
}
|
||||
|
||||
// majorKeys used for aspect table (外行星可选由前端过滤).
|
||||
var majorKeys = []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"}
|
||||
|
||||
// Aspects computes major aspects among primary bodies.
|
||||
func Aspects(chart Chart) []Aspect {
|
||||
byKey := map[string]Body{}
|
||||
for _, p := range chart.Planets {
|
||||
byKey[p.Key] = p
|
||||
}
|
||||
var bodies []Body
|
||||
for _, k := range majorKeys {
|
||||
if b, ok := byKey[k]; ok {
|
||||
bodies = append(bodies, b)
|
||||
}
|
||||
}
|
||||
out := make([]Aspect, 0)
|
||||
for i := 0; i < len(bodies); i++ {
|
||||
for j := i + 1; j < len(bodies); j++ {
|
||||
a, b := bodies[i], bodies[j]
|
||||
diff := AngleDiff(a.Lon, b.Lon)
|
||||
for _, def := range aspectDefs {
|
||||
orb := math.Abs(diff - def.Angle)
|
||||
if orb <= def.Orb {
|
||||
out = append(out, Aspect{
|
||||
A: a.Key, B: b.Key, ATitle: a.Title, BTitle: b.Title,
|
||||
Type: def.Type, Angle: def.Angle, Orb: round1(orb),
|
||||
Label: fmt.Sprintf("%s%s%s(容许%.1f°)", a.Title, def.Label, b.Title, orb),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Orb != out[j].Orb {
|
||||
return out[i].Orb < out[j].Orb
|
||||
}
|
||||
return out[i].Label < out[j].Label
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// AspectsAsMaps for JSON embedding.
|
||||
func AspectsAsMaps(list []Aspect) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for _, a := range list {
|
||||
out = append(out, map[string]any{
|
||||
"a": a.A, "b": a.B, "a_title": a.ATitle, "b_title": a.BTitle,
|
||||
"type": a.Type, "angle": a.Angle, "orb": a.Orb, "label": a.Label,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func round1(x float64) float64 {
|
||||
return math.Round(x*10) / 10
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package natal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAspectsDeterministic(t *testing.T) {
|
||||
c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := Aspects(c)
|
||||
b := Aspects(c)
|
||||
if len(a) == 0 {
|
||||
t.Fatal("expected some aspects")
|
||||
}
|
||||
if len(a) != len(b) || a[0].Label != b[0].Label {
|
||||
t.Fatalf("not deterministic: %+v vs %+v", a[0], b[0])
|
||||
}
|
||||
for _, asp := range a {
|
||||
if asp.Orb < 0 || asp.Orb > 8.1 {
|
||||
t.Fatalf("orb out of range: %+v", asp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,148 @@
|
||||
package natal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// City is a built-in birth place with WGS84 coordinates.
|
||||
type City struct {
|
||||
Name string
|
||||
Lat float64
|
||||
Lng float64
|
||||
}
|
||||
|
||||
// Cities lists common CN cities for MVP geocode (no network).
|
||||
var Cities = []City{
|
||||
{"北京", 39.9042, 116.4074},
|
||||
{"上海", 31.2304, 121.4737},
|
||||
{"广州", 23.1291, 113.2644},
|
||||
{"深圳", 22.5431, 114.0579},
|
||||
{"杭州", 30.2741, 120.1551},
|
||||
{"成都", 30.5728, 104.0668},
|
||||
{"重庆", 29.5630, 106.5516},
|
||||
{"武汉", 30.5928, 114.3055},
|
||||
{"西安", 34.3416, 108.9398},
|
||||
{"南京", 32.0603, 118.7969},
|
||||
{"天津", 39.3434, 117.3616},
|
||||
{"苏州", 31.2989, 120.5853},
|
||||
{"长沙", 28.2282, 112.9388},
|
||||
{"郑州", 34.7466, 113.6254},
|
||||
{"青岛", 36.0671, 120.3826},
|
||||
{"大连", 38.9140, 121.6147},
|
||||
{"厦门", 24.4798, 118.0894},
|
||||
{"福州", 26.0745, 119.2965},
|
||||
{"昆明", 25.0389, 102.7183},
|
||||
{"贵阳", 26.6470, 106.6302},
|
||||
{"南宁", 22.8170, 108.3665},
|
||||
{"海口", 20.0440, 110.1999},
|
||||
{"哈尔滨", 45.8038, 126.5349},
|
||||
{"长春", 43.8171, 125.3235},
|
||||
{"沈阳", 41.8057, 123.4315},
|
||||
{"石家庄", 38.0428, 114.5149},
|
||||
{"太原", 37.8706, 112.5489},
|
||||
{"济南", 36.6512, 117.1201},
|
||||
{"合肥", 31.8206, 117.2272},
|
||||
{"南昌", 28.6820, 115.8579},
|
||||
{"兰州", 36.0611, 103.8343},
|
||||
{"乌鲁木齐", 43.8256, 87.6168},
|
||||
{"拉萨", 29.6520, 91.1721},
|
||||
{"呼和浩特", 40.8414, 111.7519},
|
||||
{"银川", 38.4872, 106.2309},
|
||||
{"西宁", 36.6171, 101.7782},
|
||||
{"香港", 22.3193, 114.1694},
|
||||
{"澳门", 22.1987, 113.5439},
|
||||
{"台北", 25.0330, 121.5654},
|
||||
{"宁波", 29.8683, 121.5440},
|
||||
{"无锡", 31.4912, 120.3119},
|
||||
{"佛山", 23.0215, 113.1214},
|
||||
{"东莞", 23.0207, 113.7518},
|
||||
{"温州", 27.9943, 120.6994},
|
||||
{"泉州", 24.8741, 118.6759},
|
||||
{"珠海", 22.2710, 113.5767},
|
||||
}
|
||||
|
||||
// DefaultCoords is used when place is missing (东八区中部近似).
|
||||
const DefaultLat = 30.0
|
||||
const DefaultLng = 114.0
|
||||
|
||||
// ResolvePlace returns lat/lng and whether a named city matched.
|
||||
// Accepts plain city ("杭州") or 省市区 ("浙江省 杭州市 西湖区" / "北京市 市辖区 朝阳区").
|
||||
func ResolvePlace(place string) (lat, lng float64, matched string, ok bool) {
|
||||
place = strings.TrimSpace(place)
|
||||
if place == "" {
|
||||
return DefaultLat, DefaultLng, "", false
|
||||
}
|
||||
// exact city table hit
|
||||
for _, c := range Cities {
|
||||
if c.Name == place {
|
||||
return c.Lat, c.Lng, c.Name, true
|
||||
}
|
||||
}
|
||||
tokens := splitPlace(place)
|
||||
// prefer matching city-level tokens (usually 2nd), then others
|
||||
order := append([]string{}, tokens...)
|
||||
if len(tokens) >= 2 {
|
||||
order = append([]string{tokens[1]}, tokens[0])
|
||||
if len(tokens) >= 3 {
|
||||
order = append(order, tokens[2:]...)
|
||||
}
|
||||
}
|
||||
for _, tok := range order {
|
||||
if tok == "" || tok == "市辖区" || tok == "县" {
|
||||
continue
|
||||
}
|
||||
key := normalizeAdmin(tok)
|
||||
for _, c := range Cities {
|
||||
if c.Name == key || strings.Contains(tok, c.Name) || strings.Contains(c.Name, key) {
|
||||
return c.Lat, c.Lng, c.Name, true
|
||||
}
|
||||
}
|
||||
}
|
||||
// province-only fallback: use first token city if 直辖市
|
||||
if len(tokens) > 0 {
|
||||
key := normalizeAdmin(tokens[0])
|
||||
for _, c := range Cities {
|
||||
if c.Name == key {
|
||||
return c.Lat, c.Lng, c.Name, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return DefaultLat, DefaultLng, "", false
|
||||
}
|
||||
|
||||
func splitPlace(s string) []string {
|
||||
s = strings.ReplaceAll(s, "/", " ")
|
||||
s = strings.ReplaceAll(s, "/", " ")
|
||||
s = strings.ReplaceAll(s, ",", " ")
|
||||
s = strings.ReplaceAll(s, ",", " ")
|
||||
parts := strings.Fields(s)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeAdmin(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
suffixes := []string{"特别行政区", "维吾尔自治区", "壮族自治区", "回族自治区", "自治区", "省", "市", "地区", "盟"}
|
||||
for _, suf := range suffixes {
|
||||
if strings.HasSuffix(s, suf) && utf8.RuneCountInString(s) > utf8.RuneCountInString(suf) {
|
||||
return strings.TrimSuffix(s, suf)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CityNames returns names for API/UI pickers.
|
||||
func CityNames() []string {
|
||||
out := make([]string, len(Cities))
|
||||
for i, c := range Cities {
|
||||
out[i] = c.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Package natal computes tropical whole-sign natal charts via Swiss Ephemeris.
|
||||
package natal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris"
|
||||
)
|
||||
|
||||
// Body is a chart point.
|
||||
type Body struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Lon float64 `json:"lon"`
|
||||
SignKey string `json:"sign_key"`
|
||||
Sign string `json:"sign"`
|
||||
Degree float64 `json:"degree"` // 0–30 within sign
|
||||
House int `json:"house"`
|
||||
Element string `json:"element"`
|
||||
Modality string `json:"modality"`
|
||||
}
|
||||
|
||||
// House is a whole-sign house.
|
||||
type House struct {
|
||||
Num int `json:"num"`
|
||||
Sign string `json:"sign"`
|
||||
Key string `json:"sign_key"`
|
||||
}
|
||||
|
||||
// Chart is a natal chart snapshot.
|
||||
type Chart struct {
|
||||
Sun, Moon, Rise Body
|
||||
Planets []Body
|
||||
Houses []House
|
||||
Lat, Lng float64
|
||||
PlaceLabel string
|
||||
HasTime bool
|
||||
HasPlace bool
|
||||
Note string
|
||||
// Instant is the UTC moment used for ephemeris (noon default when time missing).
|
||||
Instant time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type signMeta struct {
|
||||
Key, Label, Element, Modality string
|
||||
}
|
||||
|
||||
var signs = []signMeta{
|
||||
{"aries", "白羊", "火", "开创"},
|
||||
{"taurus", "金牛", "土", "固定"},
|
||||
{"gemini", "双子", "风", "变动"},
|
||||
{"cancer", "巨蟹", "水", "开创"},
|
||||
{"leo", "狮子", "火", "固定"},
|
||||
{"virgo", "处女", "土", "变动"},
|
||||
{"libra", "天秤", "风", "开创"},
|
||||
{"scorpio", "天蝎", "水", "固定"},
|
||||
{"sagittarius", "射手", "火", "变动"},
|
||||
{"capricorn", "摩羯", "土", "开创"},
|
||||
{"aquarius", "水瓶", "风", "固定"},
|
||||
{"pisces", "双鱼", "水", "变动"},
|
||||
}
|
||||
|
||||
var bodyTitles = map[string]string{
|
||||
"sun": "太阳", "moon": "月亮", "rise": "上升",
|
||||
"mercury": "水星", "venus": "金星", "mars": "火星",
|
||||
"jupiter": "木星", "saturn": "土星", "uranus": "天王星",
|
||||
"neptune": "海王星", "pluto": "冥王星",
|
||||
}
|
||||
|
||||
// Compute builds a natal chart. birthTime is HH:MM local; place is city name.
|
||||
func Compute(birthDate time.Time, birthTime *string, place *string) (Chart, error) {
|
||||
lat, lng := DefaultLat, DefaultLng
|
||||
placeLabel := "默认(未填出生地)"
|
||||
hasPlace := false
|
||||
if place != nil && *place != "" {
|
||||
if la, ln, name, ok := ResolvePlace(*place); ok {
|
||||
lat, lng, placeLabel, hasPlace = la, ln, name, true
|
||||
} else {
|
||||
placeLabel = *place + "(未匹配城市,用默认坐标)"
|
||||
}
|
||||
}
|
||||
|
||||
h, mi := 12, 0 // noon default when time missing
|
||||
hasTime := false
|
||||
if birthTime != nil && *birthTime != "" {
|
||||
if hh, mm, ok := parseHM(*birthTime); ok {
|
||||
h, mi, hasTime = hh, mm, true
|
||||
}
|
||||
}
|
||||
|
||||
// Treat civil time as UTC+8 for CN MVP (deterministic).
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
local := time.Date(birthDate.Year(), birthDate.Month(), birthDate.Day(), h, mi, 0, 0, loc)
|
||||
utc := local.UTC()
|
||||
|
||||
return ComputeAt(utc, lat, lng, placeLabel, hasTime, hasPlace)
|
||||
}
|
||||
|
||||
// ComputeAt builds a chart for an exact UTC instant and coordinates.
|
||||
func ComputeAt(utc time.Time, lat, lng float64, placeLabel string, hasTime, hasPlace bool) (Chart, error) {
|
||||
utc = utc.UTC()
|
||||
jd := ephemeris.JulianDayUT(utc)
|
||||
lons, err := ephemeris.AllPlanetLons(jd)
|
||||
if err != nil {
|
||||
return Chart{}, fmt.Errorf("ephemeris: %w", err)
|
||||
}
|
||||
asc, err := ephemeris.Ascendant(jd, lat, lng)
|
||||
if err != nil {
|
||||
return Chart{}, fmt.Errorf("ephemeris asc: %w", err)
|
||||
}
|
||||
|
||||
ch := ChartFromLons(lons, asc, lat, lng, placeLabel, hasTime, hasPlace)
|
||||
ch.Instant = utc
|
||||
ch.Note = fmt.Sprintf("热带黄道 · 整宫制 · 星历 %s。", ephemeris.Backend())
|
||||
if !hasTime {
|
||||
ch.Note += " 未填出生时,上升与宫位按正午估算。"
|
||||
}
|
||||
if !hasPlace {
|
||||
ch.Note += " 填写出生地可提升上升准确度。"
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ChartFromLons builds a whole-sign chart from body longitudes and ASC.
|
||||
func ChartFromLons(lons map[string]float64, asc float64, lat, lng float64, placeLabel string, hasTime, hasPlace bool) Chart {
|
||||
ascSignIdx := signIndex(asc)
|
||||
mk := func(key string, lon float64) Body {
|
||||
title := bodyTitles[key]
|
||||
if title == "" {
|
||||
title = key
|
||||
}
|
||||
idx := signIndex(lon)
|
||||
s := signs[idx]
|
||||
house := wholeSignHouse(ascSignIdx, idx)
|
||||
return Body{
|
||||
Key: key, Title: title, Lon: norm360(lon),
|
||||
SignKey: s.Key, Sign: s.Label, Degree: math.Mod(norm360(lon), 30),
|
||||
House: house, Element: s.Element, Modality: s.Modality,
|
||||
}
|
||||
}
|
||||
|
||||
sun := mk("sun", lons["sun"])
|
||||
moon := mk("moon", lons["moon"])
|
||||
rise := mk("rise", asc)
|
||||
|
||||
planets := []Body{
|
||||
sun, moon, rise,
|
||||
mk("mercury", lons["mercury"]),
|
||||
mk("venus", lons["venus"]),
|
||||
mk("mars", lons["mars"]),
|
||||
mk("jupiter", lons["jupiter"]),
|
||||
mk("saturn", lons["saturn"]),
|
||||
mk("uranus", lons["uranus"]),
|
||||
mk("neptune", lons["neptune"]),
|
||||
mk("pluto", lons["pluto"]),
|
||||
}
|
||||
|
||||
houses := make([]House, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
idx := (ascSignIdx + i) % 12
|
||||
houses[i] = House{Num: i + 1, Sign: signs[idx].Label, Key: signs[idx].Key}
|
||||
}
|
||||
|
||||
return Chart{
|
||||
Sun: sun, Moon: moon, Rise: rise, Planets: planets, Houses: houses,
|
||||
Lat: lat, Lng: lng, PlaceLabel: placeLabel, HasTime: hasTime, HasPlace: hasPlace,
|
||||
}
|
||||
}
|
||||
|
||||
// BodyLon returns longitude for a key, or 0.
|
||||
func BodyLon(c Chart, key string) float64 {
|
||||
for _, p := range c.Planets {
|
||||
if p.Key == key {
|
||||
return p.Lon
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SignMetaByKey returns label/element for a sign key.
|
||||
func SignMetaByKey(key string) (label, element, modality string) {
|
||||
for _, s := range signs {
|
||||
if s.Key == key {
|
||||
return s.Label, s.Element, s.Modality
|
||||
}
|
||||
}
|
||||
return key, "", ""
|
||||
}
|
||||
|
||||
// SignByIndex returns sign meta.
|
||||
func SignByIndex(i int) (key, label, element, modality string) {
|
||||
s := signs[((i%12)+12)%12]
|
||||
return s.Key, s.Label, s.Element, s.Modality
|
||||
}
|
||||
|
||||
func wholeSignHouse(ascIdx, bodyIdx int) int {
|
||||
return ((bodyIdx-ascIdx)%12+12)%12 + 1
|
||||
}
|
||||
|
||||
func signIndex(lon float64) int {
|
||||
return int(math.Floor(norm360(lon)/30.0)) % 12
|
||||
}
|
||||
|
||||
func parseHM(s string) (h, m int, ok bool) {
|
||||
var hh, mm int
|
||||
n, err := fmt.Sscanf(s, "%d:%d", &hh, &mm)
|
||||
if err != nil || n < 1 || hh < 0 || hh > 23 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if n == 1 {
|
||||
mm = 0
|
||||
}
|
||||
if mm < 0 || mm > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hh, mm, true
|
||||
}
|
||||
|
||||
func norm360(x float64) float64 {
|
||||
x = math.Mod(x, 360)
|
||||
if x < 0 {
|
||||
x += 360
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// AngleDiff returns smallest absolute ecliptic separation.
|
||||
func AngleDiff(a, b float64) float64 {
|
||||
d := math.Abs(norm360(a) - norm360(b))
|
||||
if d > 180 {
|
||||
d = 360 - d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// MidpointLon returns the shorter-arc midpoint of two longitudes.
|
||||
func MidpointLon(a, b float64) float64 {
|
||||
a, b = norm360(a), norm360(b)
|
||||
d := b - a
|
||||
if d > 180 {
|
||||
d -= 360
|
||||
} else if d < -180 {
|
||||
d += 360
|
||||
}
|
||||
return norm360(a + d/2)
|
||||
}
|
||||
|
||||
// SignIndex is exported for overlay house mapping.
|
||||
func SignIndex(lon float64) int { return signIndex(lon) }
|
||||
|
||||
// WholeSignHouse exported for overlay.
|
||||
func WholeSignHouse(ascIdx, bodyIdx int) int { return wholeSignHouse(ascIdx, bodyIdx) }
|
||||
@@ -0,0 +1,55 @@
|
||||
package natal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeDeterministic(t *testing.T) {
|
||||
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
|
||||
bt := "10:30"
|
||||
place := "上海"
|
||||
a, err := Compute(birth, &bt, &place)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Compute(birth, &bt, &place)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Sun.Sign != b.Sun.Sign || a.Moon.Lon != b.Moon.Lon || a.Rise.Sign != b.Rise.Sign {
|
||||
t.Fatal("not deterministic")
|
||||
}
|
||||
if len(a.Planets) < 8 || len(a.Houses) != 12 {
|
||||
t.Fatalf("planets=%d houses=%d", len(a.Planets), len(a.Houses))
|
||||
}
|
||||
if a.Sun.Sign == "" {
|
||||
t.Fatal("empty sun")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlaceBeijing(t *testing.T) {
|
||||
lat, lng, name, ok := ResolvePlace("北京")
|
||||
if !ok || name != "北京" || lat < 39 || lng < 116 {
|
||||
t.Fatalf("got %v %v %s %v", lat, lng, name, ok)
|
||||
}
|
||||
_, _, name2, ok2 := ResolvePlace("北京市 市辖区 朝阳区")
|
||||
if !ok2 || name2 != "北京" {
|
||||
t.Fatalf("pca resolve got %s %v", name2, ok2)
|
||||
}
|
||||
_, _, name3, ok3 := ResolvePlace("浙江省 杭州市 西湖区")
|
||||
if !ok3 || name3 != "杭州" {
|
||||
t.Fatalf("hangzhou resolve got %s %v", name3, ok3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSunSignMay(t *testing.T) {
|
||||
c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// mid-May → Taurus
|
||||
if c.Sun.SignKey != "taurus" {
|
||||
t.Fatalf("want taurus got %s", c.Sun.SignKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
// ChartPack holds all synastry chart modes for one pair.
|
||||
type ChartPack struct {
|
||||
Composite natal.Chart
|
||||
Davison natal.Chart
|
||||
MarksMe natal.Chart
|
||||
MarksOther natal.Chart
|
||||
Overlay OverlayChart
|
||||
CompositeProgressed natal.Chart
|
||||
DavisonProgressed natal.Chart
|
||||
MarksProgressed natal.Chart
|
||||
AsOf time.Time
|
||||
}
|
||||
|
||||
// OverlayHouse is B's planet in A's whole-sign house.
|
||||
type OverlayHouse struct {
|
||||
PlanetKey string `json:"planet_key"`
|
||||
Planet string `json:"planet"`
|
||||
Sign string `json:"sign"`
|
||||
House int `json:"house"`
|
||||
HouseTip string `json:"house_tip"`
|
||||
}
|
||||
|
||||
// OverlayChart is the pairing/overlay table.
|
||||
type OverlayChart struct {
|
||||
HousesA []natal.House `json:"houses_a"`
|
||||
Entries []OverlayHouse `json:"entries"`
|
||||
Tip string `json:"tip"`
|
||||
}
|
||||
|
||||
// BuildCharts computes five main charts + three progressed variants.
|
||||
func BuildCharts(a, b natal.Chart, asOf time.Time) (ChartPack, error) {
|
||||
if asOf.IsZero() {
|
||||
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
|
||||
}
|
||||
comp := CompositeChart(a, b)
|
||||
dav, err := DavisonChart(a, b)
|
||||
if err != nil {
|
||||
return ChartPack{}, err
|
||||
}
|
||||
marksMe := MarksChart(dav, a)
|
||||
marksOther := MarksChart(dav, b)
|
||||
overlay := Overlay(a, b)
|
||||
|
||||
progA, err := ProgressChart(a, asOf)
|
||||
if err != nil {
|
||||
return ChartPack{}, err
|
||||
}
|
||||
progB, err := ProgressChart(b, asOf)
|
||||
if err != nil {
|
||||
return ChartPack{}, err
|
||||
}
|
||||
compProg := CompositeChart(progA, progB)
|
||||
davProg, err := ProgressChart(dav, asOf)
|
||||
if err != nil {
|
||||
return ChartPack{}, err
|
||||
}
|
||||
marksProg := MarksChart(davProg, progA)
|
||||
|
||||
return ChartPack{
|
||||
Composite: comp, Davison: dav,
|
||||
MarksMe: marksMe, MarksOther: marksOther,
|
||||
Overlay: overlay,
|
||||
CompositeProgressed: compProg,
|
||||
DavisonProgressed: davProg,
|
||||
MarksProgressed: marksProg,
|
||||
AsOf: asOf,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompositeChart midpoints each body longitude (shortest arc); ASC mid of rises.
|
||||
func CompositeChart(a, b natal.Chart) natal.Chart {
|
||||
lons := map[string]float64{}
|
||||
for _, k := range ephemeris.PlanetOrder {
|
||||
lons[k] = natal.MidpointLon(natal.BodyLon(a, k), natal.BodyLon(b, k))
|
||||
}
|
||||
asc := natal.MidpointLon(a.Rise.Lon, b.Rise.Lon)
|
||||
lat := (a.Lat + b.Lat) / 2
|
||||
lng := midLng(a.Lng, b.Lng)
|
||||
ch := natal.ChartFromLons(lons, asc, lat, lng, "组合盘", a.HasTime && b.HasTime, a.HasPlace || b.HasPlace)
|
||||
ch.Instant = midTime(a.Instant, b.Instant)
|
||||
ch.Note = "组合盘:双方行星黄经中点,看关系整体气质。"
|
||||
return ch
|
||||
}
|
||||
|
||||
// DavisonChart uses midpoint birth time + midpoint coordinates, then recomputes.
|
||||
func DavisonChart(a, b natal.Chart) (natal.Chart, error) {
|
||||
inst := midTime(a.Instant, b.Instant)
|
||||
lat := (a.Lat + b.Lat) / 2
|
||||
lng := midLng(a.Lng, b.Lng)
|
||||
ch, err := natal.ComputeAt(inst, lat, lng, "时空盘", true, true)
|
||||
if err != nil {
|
||||
return natal.Chart{}, err
|
||||
}
|
||||
ch.Note = "时空盘:出生时刻与坐标中点再排盘,看长期现实走向。"
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// MarksChart midpoints each Davison body with a natal chart (我/TA 视角).
|
||||
func MarksChart(davison, natalCh natal.Chart) natal.Chart {
|
||||
lons := map[string]float64{}
|
||||
for _, k := range ephemeris.PlanetOrder {
|
||||
lons[k] = natal.MidpointLon(natal.BodyLon(davison, k), natal.BodyLon(natalCh, k))
|
||||
}
|
||||
asc := natal.MidpointLon(davison.Rise.Lon, natalCh.Rise.Lon)
|
||||
ch := natal.ChartFromLons(lons, asc, natalCh.Lat, natalCh.Lng, "马克斯盘", true, true)
|
||||
ch.Instant = midTime(davison.Instant, natalCh.Instant)
|
||||
ch.Note = "马克斯盘:时空盘与本命中点,看关系中的内在态度。"
|
||||
return ch
|
||||
}
|
||||
|
||||
// Overlay maps B planets into A's whole-sign houses.
|
||||
func Overlay(a, b natal.Chart) OverlayChart {
|
||||
ascIdx := natal.SignIndex(a.Rise.Lon)
|
||||
tips := houseTips()
|
||||
entries := make([]OverlayHouse, 0, len(b.Planets))
|
||||
for _, p := range b.Planets {
|
||||
if p.Key == "rise" {
|
||||
continue
|
||||
}
|
||||
h := natal.WholeSignHouse(ascIdx, natal.SignIndex(p.Lon))
|
||||
tip := tips[h]
|
||||
entries = append(entries, OverlayHouse{
|
||||
PlanetKey: p.Key, Planet: p.Title, Sign: p.Sign, House: h, HouseTip: tip,
|
||||
})
|
||||
}
|
||||
return OverlayChart{
|
||||
HousesA: a.Houses,
|
||||
Entries: entries,
|
||||
Tip: "配对盘:对方行星落入我方整宫制宫位,提示生活领域互动。",
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressChart applies secondary progression (1 day ≈ 1 year) from chart.Instant to asOf.
|
||||
func ProgressChart(ch natal.Chart, asOf time.Time) (natal.Chart, error) {
|
||||
if ch.Instant.IsZero() {
|
||||
return ch, nil
|
||||
}
|
||||
years := asOf.UTC().Sub(ch.Instant).Hours() / 24.0 / 365.24219
|
||||
if years < 0 {
|
||||
years = 0
|
||||
}
|
||||
// Add whole days to avoid time.Duration overflow/precision issues for large ages.
|
||||
days := int(years + 0.5)
|
||||
progUTC := ch.Instant.AddDate(0, 0, days)
|
||||
out, err := natal.ComputeAt(progUTC, ch.Lat, ch.Lng, ch.PlaceLabel+"·次限", ch.HasTime, ch.HasPlace)
|
||||
if err != nil {
|
||||
return natal.Chart{}, err
|
||||
}
|
||||
out.Note = fmt.Sprintf("次限推运至 %s(约 %.1f 年)。", asOf.Format("2006-01-02"), years)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func midTime(a, b time.Time) time.Time {
|
||||
if a.IsZero() && b.IsZero() {
|
||||
return time.Time{}
|
||||
}
|
||||
if a.IsZero() {
|
||||
return b
|
||||
}
|
||||
if b.IsZero() {
|
||||
return a
|
||||
}
|
||||
return a.Add(b.Sub(a) / 2)
|
||||
}
|
||||
|
||||
func midLng(a, b float64) float64 {
|
||||
// Geographic mean (CN MVP; both births typically East Asia).
|
||||
return (a + b) / 2
|
||||
}
|
||||
|
||||
func houseTips() map[int]string {
|
||||
return map[int]string{
|
||||
1: "自我与第一印象", 2: "资源与安全感", 3: "沟通与日常", 4: "家庭与根基",
|
||||
5: "恋爱表达与创造", 6: "协作与健康节奏", 7: "一对一关系", 8: "深度与共享",
|
||||
9: "视野与信念", 10: "事业与对外形象", 11: "社群与愿景", 12: "内在与疗愈",
|
||||
}
|
||||
}
|
||||
|
||||
func chartSummaryOut(c natal.Chart, tip string) map[string]any {
|
||||
asp := natal.Aspects(c)
|
||||
aspMaps := natal.AspectsAsMaps(asp)
|
||||
previewN := 4
|
||||
if len(aspMaps) < previewN {
|
||||
previewN = len(aspMaps)
|
||||
}
|
||||
t := tip
|
||||
if t == "" {
|
||||
t = c.Note
|
||||
}
|
||||
return map[string]any{
|
||||
"asc_lon": c.Rise.Lon,
|
||||
"planets": bodiesOut(c),
|
||||
"houses": housesOut(c),
|
||||
"note": c.Note,
|
||||
"place": c.PlaceLabel,
|
||||
"tip": t,
|
||||
"aspects_preview": aspMaps[:previewN],
|
||||
"sun": c.Sun.Sign,
|
||||
"moon": c.Moon.Sign,
|
||||
"rise": c.Rise.Sign,
|
||||
}
|
||||
}
|
||||
|
||||
func overlayOut(o OverlayChart) map[string]any {
|
||||
entries := make([]map[string]any, 0, len(o.Entries))
|
||||
for _, e := range o.Entries {
|
||||
entries = append(entries, map[string]any{
|
||||
"planet_key": e.PlanetKey, "planet": e.Planet, "sign": e.Sign,
|
||||
"house": e.House, "house_tip": e.HouseTip,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"tip": o.Tip, "entries": entries, "houses_a": housesOut(natal.Chart{Houses: o.HousesA}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
// Report is free summary + gated detail for a synastry GrowthReport.
|
||||
type Report struct {
|
||||
Summary map[string]any
|
||||
Detail map[string]any
|
||||
}
|
||||
|
||||
// BuildReport builds multi-chart synastry content (五主盘 + 三推运).
|
||||
func BuildReport(a, b natal.Chart, aName, bName string, asOf time.Time) (Report, error) {
|
||||
if aName == "" {
|
||||
aName = "我"
|
||||
}
|
||||
if bName == "" {
|
||||
bName = "TA"
|
||||
}
|
||||
if asOf.IsZero() {
|
||||
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
|
||||
}
|
||||
idx := Compute(a, b, aName, bName)
|
||||
cross := CrossAspects(a, b)
|
||||
crossMaps := natal.AspectsAsMaps(cross)
|
||||
previewN := 4
|
||||
if len(crossMaps) < previewN {
|
||||
previewN = len(crossMaps)
|
||||
}
|
||||
|
||||
pack, err := BuildCharts(a, b, asOf)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
asOfStr := asOf.Format("2006-01-02")
|
||||
|
||||
chartsSummary := map[string]any{
|
||||
"compare": map[string]any{
|
||||
"tip": "比较盘:双方叠合,看互动与吸引。",
|
||||
"chart_a": map[string]any{
|
||||
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
|
||||
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
|
||||
"sun": a.Sun.Sign, "moon": a.Moon.Sign, "rise": a.Rise.Sign,
|
||||
},
|
||||
"chart_b": map[string]any{
|
||||
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
|
||||
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
|
||||
"sun": b.Sun.Sign, "moon": b.Moon.Sign, "rise": b.Rise.Sign,
|
||||
},
|
||||
"aspects_preview": crossMaps[:previewN],
|
||||
},
|
||||
"composite": chartSummaryOut(pack.Composite, "组合盘:关系整体气质。"),
|
||||
"davison": chartSummaryOut(pack.Davison, "时空盘:长期现实走向。"),
|
||||
"marks_me": chartSummaryOut(pack.MarksMe, "马克斯盘·我:我对这段关系的内在态度。"),
|
||||
"marks_other": chartSummaryOut(pack.MarksOther, "马克斯盘·TA:TA对这段关系的内在态度。"),
|
||||
"overlay": overlayOut(pack.Overlay),
|
||||
"composite_progressed": chartSummaryOut(pack.CompositeProgressed, "组合次限:关系当下成长态。"),
|
||||
"davison_progressed": chartSummaryOut(pack.DavisonProgressed, "时空次限:现实课题的阶段性。"),
|
||||
"marks_progressed": chartSummaryOut(pack.MarksProgressed, "马盘推运:我对关系的阶段心态。"),
|
||||
}
|
||||
|
||||
summary := map[string]any{
|
||||
"title": "合盘·基础",
|
||||
"headline": idx.Summary,
|
||||
"one_liner": fmt.Sprintf("%s与%s:五盘合观互动、气质与现实节奏。", aName, bName),
|
||||
"overview": fmt.Sprintf("%s(日%s月%s升%s)× %s(日%s月%s升%s)。", aName, a.Sun.Sign, a.Moon.Sign, a.Rise.Sign, bName, b.Sun.Sign, b.Moon.Sign, b.Rise.Sign),
|
||||
"me_name": aName,
|
||||
"other_name": bName,
|
||||
"love_index": idx.Love,
|
||||
"friend_index": idx.Friend,
|
||||
"marriage_index": idx.Marriage,
|
||||
"match_indices": idx.AsMap(),
|
||||
"love_note": idx.LoveNote,
|
||||
"friend_note": idx.FriendNote,
|
||||
"marriage_note": idx.MarriageNote,
|
||||
"as_of": asOfStr,
|
||||
// backward-compat top-level compare charts
|
||||
"chart_a": map[string]any{
|
||||
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
|
||||
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
|
||||
},
|
||||
"chart_b": map[string]any{
|
||||
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
|
||||
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
|
||||
},
|
||||
"aspects_preview": crossMaps[:previewN],
|
||||
"charts": chartsSummary,
|
||||
"keywords": []string{"合盘", "比较盘", "组合盘", "时空盘", fmt.Sprintf("恋爱%d", idx.Love)},
|
||||
"strengths_preview": []string{
|
||||
fmt.Sprintf("恋爱指数 %d", idx.Love),
|
||||
fmt.Sprintf("友情指数 %d", idx.Friend),
|
||||
fmt.Sprintf("婚姻指数 %d", idx.Marriage),
|
||||
},
|
||||
"blind_spots_preview": []string{"完整相位、推运深文案与落宫解读见深度版。"},
|
||||
}
|
||||
|
||||
bullets := make([]string, 0, min(10, len(cross)))
|
||||
for i, c := range cross {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
bullets = append(bullets, c.Label)
|
||||
}
|
||||
|
||||
compAsp := natal.AspectsAsMaps(natal.Aspects(pack.Composite))
|
||||
davAsp := natal.AspectsAsMaps(natal.Aspects(pack.Davison))
|
||||
progAsp := natal.AspectsAsMaps(natal.Aspects(pack.CompositeProgressed))
|
||||
|
||||
detail := map[string]any{
|
||||
"title": "合盘·完整分析",
|
||||
"as_of": asOfStr,
|
||||
"aspects": crossMaps,
|
||||
"charts": map[string]any{
|
||||
"compare": map[string]any{"aspects": crossMaps},
|
||||
"composite": map[string]any{"aspects": compAsp, "planets": bodiesOut(pack.Composite)},
|
||||
"davison": map[string]any{"aspects": davAsp, "planets": bodiesOut(pack.Davison)},
|
||||
"marks_me": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksMe)), "planets": bodiesOut(pack.MarksMe)},
|
||||
"marks_other": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksOther)), "planets": bodiesOut(pack.MarksOther)},
|
||||
"overlay": overlayOut(pack.Overlay),
|
||||
"composite_progressed": map[string]any{"aspects": progAsp, "planets": bodiesOut(pack.CompositeProgressed), "note": pack.CompositeProgressed.Note},
|
||||
"davison_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.DavisonProgressed)), "planets": bodiesOut(pack.DavisonProgressed), "note": pack.DavisonProgressed.Note},
|
||||
"marks_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksProgressed)), "planets": bodiesOut(pack.MarksProgressed), "note": pack.MarksProgressed.Note},
|
||||
},
|
||||
"sections": []map[string]any{
|
||||
{"title": "恋爱互动", "body": idx.LoveNote, "bullets": []string{"说清需要比猜测更有效", "张力相位日宜慢半拍回应"}},
|
||||
{"title": "友情节奏", "body": idx.FriendNote, "bullets": []string{"共同兴趣维系轻松感", "尊重彼此独处需求"}},
|
||||
{"title": "长期相处", "body": idx.MarriageNote, "bullets": []string{"共同节奏与边界同等重要", "把期待写成说明书"}},
|
||||
{"title": "跨盘相位", "body": "比较盘相位描述双方能量如何彼此激活或拉扯。", "bullets": bullets},
|
||||
{"title": "组合与时空", "body": "组合盘看关系气质,时空盘看共同现实课题;次限呈现阶段性成长。", "bullets": []string{
|
||||
fmt.Sprintf("组合盘日%s月%s升%s", pack.Composite.Sun.Sign, pack.Composite.Moon.Sign, pack.Composite.Rise.Sign),
|
||||
fmt.Sprintf("时空盘日%s月%s升%s", pack.Davison.Sun.Sign, pack.Davison.Moon.Sign, pack.Davison.Rise.Sign),
|
||||
fmt.Sprintf("推运日期 %s", asOfStr),
|
||||
}},
|
||||
{"title": "配对落宫", "body": pack.Overlay.Tip, "bullets": overlayBullets(pack.Overlay)},
|
||||
},
|
||||
"growth_plan": []map[string]any{
|
||||
{"phase": "本周", "focus": "约一次轻松同步:各说一件欣赏与一件需要。"},
|
||||
{"phase": "本月", "focus": "为高频摩擦点写一条可执行约定。"},
|
||||
{"phase": "长期", "focus": "每季度复盘三指数变化与相处舒适度。"},
|
||||
},
|
||||
"conversation_scripts": []string{
|
||||
"我想听听你怎么看我们的节奏,不急着定结论。",
|
||||
"我需要一点空间整理,不是拒绝连接。",
|
||||
"我们共同目标是……,这一步只做一件事。",
|
||||
},
|
||||
"behavior_pattern": idx.LoveNote,
|
||||
"relation_style": idx.FriendNote,
|
||||
"growth_direction": idx.MarriageNote,
|
||||
}
|
||||
return Report{Summary: summary, Detail: detail}, nil
|
||||
}
|
||||
|
||||
func overlayBullets(o OverlayChart) []string {
|
||||
out := make([]string, 0, 6)
|
||||
for i, e := range o.Entries {
|
||||
if i >= 6 {
|
||||
break
|
||||
}
|
||||
if e.PlanetKey == "sun" || e.PlanetKey == "moon" || e.PlanetKey == "venus" || e.PlanetKey == "mars" {
|
||||
out = append(out, fmt.Sprintf("%s在我方%d宫(%s)", e.Planet, e.House, e.HouseTip))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && len(o.Entries) > 0 {
|
||||
e := o.Entries[0]
|
||||
out = append(out, fmt.Sprintf("%s在我方%d宫", e.Planet, e.House))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CrossAspects finds aspects between chart A bodies and chart B bodies.
|
||||
func CrossAspects(a, b natal.Chart) []natal.Aspect {
|
||||
keys := []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"}
|
||||
byA := map[string]natal.Body{}
|
||||
byB := map[string]natal.Body{}
|
||||
for _, p := range a.Planets {
|
||||
byA[p.Key] = p
|
||||
}
|
||||
for _, p := range b.Planets {
|
||||
byB[p.Key] = p
|
||||
}
|
||||
defs := []struct {
|
||||
Type string
|
||||
Angle float64
|
||||
Orb float64
|
||||
Label string
|
||||
}{
|
||||
{"conjunction", 0, 8, "合相"},
|
||||
{"sextile", 60, 6, "六合"},
|
||||
{"square", 90, 7, "刑相"},
|
||||
{"trine", 120, 7, "拱相"},
|
||||
{"opposition", 180, 8, "对冲"},
|
||||
}
|
||||
out := make([]natal.Aspect, 0)
|
||||
for _, ka := range keys {
|
||||
ba, oka := byA[ka]
|
||||
if !oka {
|
||||
continue
|
||||
}
|
||||
for _, kb := range keys {
|
||||
bb, okb := byB[kb]
|
||||
if !okb {
|
||||
continue
|
||||
}
|
||||
diff := natal.AngleDiff(ba.Lon, bb.Lon)
|
||||
for _, def := range defs {
|
||||
orb := absF(diff - def.Angle)
|
||||
if orb <= def.Orb {
|
||||
out = append(out, natal.Aspect{
|
||||
A: "a:" + ba.Key, B: "b:" + bb.Key,
|
||||
ATitle: aNameTitle(ba.Title), BTitle: bNameTitle(bb.Title),
|
||||
Type: def.Type, Angle: def.Angle, Orb: round1(orb),
|
||||
Label: fmt.Sprintf("我方%s%s对方%s(容许%.1f°)", ba.Title, def.Label, bb.Title, orb),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(out); i++ {
|
||||
for j := i + 1; j < len(out); j++ {
|
||||
if out[j].Orb < out[i].Orb {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 24 {
|
||||
out = out[:24]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func aNameTitle(t string) string { return "我·" + t }
|
||||
func bNameTitle(t string) string { return "TA·" + t }
|
||||
|
||||
func bodiesOut(c natal.Chart) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(c.Planets))
|
||||
for _, p := range c.Planets {
|
||||
out = append(out, map[string]any{
|
||||
"key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey,
|
||||
"degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House,
|
||||
"lon": p.Lon, "element": p.Element, "modality": p.Modality,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func housesOut(c natal.Chart) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(c.Houses))
|
||||
for _, h := range c.Houses {
|
||||
out = append(out, map[string]any{"num": h.Num, "sign": h.Sign, "sign_key": h.Key})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func absF(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func round1(x float64) float64 {
|
||||
return float64(int(x*10+0.5)) / 10
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
func TestBuildReport(t *testing.T) {
|
||||
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("上海"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
rep, err := BuildReport(a, b, "我", "TA", asOf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Summary["love_index"] == nil || rep.Summary["chart_a"] == nil {
|
||||
t.Fatalf("summary incomplete: %#v", rep.Summary)
|
||||
}
|
||||
charts, ok := rep.Summary["charts"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("charts missing")
|
||||
}
|
||||
for _, k := range []string{"compare", "composite", "davison", "marks_me", "marks_other", "overlay",
|
||||
"composite_progressed", "davison_progressed", "marks_progressed"} {
|
||||
if charts[k] == nil {
|
||||
t.Fatalf("missing chart key %s", k)
|
||||
}
|
||||
}
|
||||
if rep.Summary["as_of"] != "2026-08-02" {
|
||||
t.Fatalf("as_of=%v", rep.Summary["as_of"])
|
||||
}
|
||||
if rep.Detail["aspects"] == nil {
|
||||
t.Fatal("detail aspects missing")
|
||||
}
|
||||
cross := CrossAspects(a, b)
|
||||
if len(cross) == 0 {
|
||||
t.Fatal("expected cross aspects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeDeterministic(t *testing.T) {
|
||||
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("10:30"), strPtr("上海"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("北京"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c1 := CompositeChart(a, b)
|
||||
c2 := CompositeChart(a, b)
|
||||
if c1.Sun.Lon != c2.Sun.Lon || c1.Rise.Sign != c2.Rise.Sign {
|
||||
t.Fatal("composite not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package synastry computes love/friend/marriage match indices from two natal charts.
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
// Indices are 0–100 match scores.
|
||||
type Indices struct {
|
||||
Love int `json:"love_index"`
|
||||
Friend int `json:"friend_index"`
|
||||
Marriage int `json:"marriage_index"`
|
||||
LoveNote string `json:"love_note"`
|
||||
FriendNote string `json:"friend_note"`
|
||||
MarriageNote string `json:"marriage_note"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Compute returns match indices for charts A and B.
|
||||
func Compute(a, b natal.Chart, aName, bName string) Indices {
|
||||
if aName == "" {
|
||||
aName = "我"
|
||||
}
|
||||
if bName == "" {
|
||||
bName = "TA"
|
||||
}
|
||||
sunDiff := natal.AngleDiff(a.Sun.Lon, b.Sun.Lon)
|
||||
moonDiff := natal.AngleDiff(a.Moon.Lon, b.Moon.Lon)
|
||||
venA := bodyLon(a, "venus")
|
||||
venB := bodyLon(b, "venus")
|
||||
marsA := bodyLon(a, "mars")
|
||||
marsB := bodyLon(b, "mars")
|
||||
venDiff := natal.AngleDiff(venA, venB)
|
||||
marsVen := natal.AngleDiff(marsA, venB)
|
||||
venMars := natal.AngleDiff(venA, marsB)
|
||||
|
||||
// Closer aspects (0/60/120 soft; 90/180 hard but charged) → higher score blended
|
||||
love := clamp(58+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+aspectBoost(marsVen)/2+aspectBoost(venMars)/2, 42, 98)
|
||||
friend := clamp(60+aspectBoost(sunDiff)+aspectBoost(moonDiff)*3/2+elemBoost(a.Sun, b.Sun), 45, 97)
|
||||
marriage := clamp(55+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+houseBoost(a, b), 40, 96)
|
||||
|
||||
return Indices{
|
||||
Love: love, Friend: friend, Marriage: marriage,
|
||||
LoveNote: fmt.Sprintf("%s与%s恋爱指数 %d:太阳相距约%.0f°,金星互动影响吸引力。", aName, bName, love, sunDiff),
|
||||
FriendNote: fmt.Sprintf("友情指数 %d:月亮与太阳元素是否合拍,决定轻松感。", friend),
|
||||
MarriageNote: fmt.Sprintf("婚姻/长期指数 %d:看重稳定与共同节奏,而非一时火花。", marriage),
|
||||
Summary: fmt.Sprintf("%s × %s:恋爱 %d · 友情 %d · 婚姻 %d", aName, bName, love, friend, marriage),
|
||||
}
|
||||
}
|
||||
|
||||
func bodyLon(c natal.Chart, key string) float64 {
|
||||
for _, p := range c.Planets {
|
||||
if p.Key == key {
|
||||
return p.Lon
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func aspectBoost(diff float64) int {
|
||||
// reward conjunction, sextile, trine; mild for square/opposition
|
||||
targets := []float64{0, 60, 90, 120, 180}
|
||||
best := 180.0
|
||||
for _, t := range targets {
|
||||
d := abs(diff - t)
|
||||
if d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case best <= 8:
|
||||
if near(diff, 90) || near(diff, 180) {
|
||||
return 8
|
||||
}
|
||||
return 14
|
||||
case best <= 12:
|
||||
return 8
|
||||
default:
|
||||
return int(6 - best/30)
|
||||
}
|
||||
}
|
||||
|
||||
func near(diff, target float64) bool {
|
||||
return abs(diff-target) <= 10
|
||||
}
|
||||
|
||||
func elemBoost(a, b natal.Body) int {
|
||||
if a.Element == b.Element {
|
||||
return 6
|
||||
}
|
||||
// fire-air / earth-water traditionally supportive
|
||||
pair := a.Element + b.Element
|
||||
if pair == "火风" || pair == "风火" || pair == "土水" || pair == "水土" {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func houseBoost(a, b natal.Chart) int {
|
||||
// same rising modality → slight boost
|
||||
if a.Rise.Modality == b.Rise.Modality {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// AsMap for JSON.
|
||||
func (i Indices) AsMap() map[string]any {
|
||||
return map[string]any{
|
||||
"love_index": i.Love, "friend_index": i.Friend, "marriage_index": i.Marriage,
|
||||
"love_note": i.LoveNote, "friend_note": i.FriendNote, "marriage_note": i.MarriageNote,
|
||||
"summary": i.Summary,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
func TestComputeIndices(t *testing.T) {
|
||||
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := natal.Compute(time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := Compute(a, b, "我", "TA")
|
||||
if idx.Love < 40 || idx.Friend < 40 || idx.Marriage < 40 {
|
||||
t.Fatalf("scores too low: %+v", idx)
|
||||
}
|
||||
if idx.Summary == "" {
|
||||
t.Fatal("empty summary")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user