Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

chartmean

chartmean is the group averaging core of LoveCartography, extracted as a standalone library. It answers one question: what is the average of several positions on a circle, and when does that average actually mean something?

It has no runtime dependencies. Just the Python standard library.

The wraparound problem

Angles wrap. That breaks ordinary averaging.

Take two positions at 359 degrees and 1 degree. They sit two degrees apart, straddling zero. The average any person would point to is 0. But the plain arithmetic mean is (359 + 1) / 2 = 180, which lands on the exact opposite side of the circle. The naive formula gives you the most wrong answer possible.

The fix is to stop treating angles as numbers on a line. Turn each angle into a point on the unit circle, average those points as vectors, and read the direction of the result back as an angle. Now 359 and 1 average to 0, the way they should.

from chartmean import circular_mean

circular_mean([359.0, 1.0]).mean   # -> 0.0

The resultant length, and when an average means anything

Averaging the angles as vectors gives you a second number for free: the length of the averaged vector. chartmean calls it r, the resultant length, and it runs from 0 to 1.

r measures how much the inputs agree.

  • When every position is the same, the vectors stack and r is 1.
  • When the positions are tight, r stays high.
  • When they scatter around the circle, the vectors point in different directions and partly cancel, so r drops.
  • When they cancel completely, say two positions exactly opposite each other, r is 0. At that point the mean angle is still a number, but it is meaningless. There is no direction the group agrees on.

This is the part that matters for real use. A circular mean always returns an angle. r is how you know whether to believe it.

LoveCartography leans on this directly. When a couple or a group has a high r for a given planet, their positions line up, and the app draws a single composite line with confidence. When r is low, the app does not invent a confident group answer. It steps back and shows each person's individual lines instead, because that is the honest picture. A low r is the signal that the group does not have one shared position to draw.

The threshold for "high enough" is 0.6 for a couple. Larger groups get a lower bar, because more positions have more room to cancel and r naturally shrinks as the group grows. scaled_coherence_threshold(n) gives the group-size bar the engine uses.

Install

pip install chartmean

Requires Python 3.11 or 3.12.

Quickstart

from chartmean import circular_mean, average_charts, scaled_coherence_threshold

# One set of angles.
result = circular_mean([10.0, 20.0, 30.0])
result.mean   # 20.0
result.r      # 0.9898..., tight cluster, trustworthy

# Opposed angles: the mean is defined but r tells you not to trust it.
circular_mean([0.0, 180.0]).r   # ~0, they cancel

# Weighting: count the first position three times as heavily.
circular_mean([0.0, 90.0], weights=[3.0, 1.0]).mean   # 18.43...

# Average several charts point by point. By default only points shared by every
# chart are returned (intersection), matching the engine. Pass points="union" to
# also include points missing from some charts and see how many members had each.
charts = [
    {"Sun": 10.0, "Moon": 100.0, "Venus": 200.0},
    {"Sun": 20.0, "Moon": 140.0},
    {"Sun": 30.0, "Venus": 220.0},
]
avg = average_charts(
    charts,
    coherence_threshold=scaled_coherence_threshold(len(charts)),
    points="union",
)
avg["Sun"].mean       # 20.0
avg["Sun"].r          # 0.9898...
avg["Sun"].coherent   # True
avg["Sun"].count      # 3 members had a Sun
avg["Moon"].count     # 2 members had a Moon (union keeps it; intersection drops it)

API

circular_mean(degrees, weights=None) -> CircularMean

The average of several angles on a circle.

  • degrees: the angles in degrees. Any finite values, read modulo 360. Must be non-empty.
  • weights: optional, one weight per angle. Omit for an equal-weight average, which reproduces the LoveCartography engine exactly. Weights must be finite, non-negative, and not all zero.

Returns a CircularMean with:

  • mean: the mean angle in the range 0 to 360, not including 360.
  • r: the resultant length from 0 to 1. Check r before trusting mean.

average_charts(charts, weights=None, coherence_threshold=0.6, points="intersection") -> dict[str, PointAverage]

Average a list of charts one point at a time. A chart is a mapping from point name to angle in degrees.

By default points="intersection", which returns only the points present in every chart and matches the LoveCartography engine. Every returned point then has a count equal to the number of charts. Pass points="union" to instead return every point present in any chart, averaging each over only the members that supply it. In union mode a point missing from some charts is skipped for those charts, and count records how many members contributed.

  • charts: the list of charts. Must be non-empty.
  • weights: optional, one weight per chart.
  • coherence_threshold: the r at or above which a point is marked coherent. Defaults to 0.6. Pass scaled_coherence_threshold(len(charts)) for the group-size bar.
  • points: "intersection" (default, engine behavior) or "union", as described above.

Returns a dict from point name to PointAverage with:

  • mean: the mean angle for that point, 0 to 360.
  • r: the resultant length for that point.
  • coherent: True when r is at or above the threshold. The comparison is inclusive.
  • count: how many charts supplied that point.

scaled_coherence_threshold(n) -> float

The coherence bar for a group of n members. It scales the 0.6 couple anchor by the square root of 2/n, which shrinks in proportion to one over the square root of n while keeping n=2 pinned at 0.6, then clamps the result between 0.30 and 0.60.

n=2 -> 0.600   n=3 -> 0.490   n=4 -> 0.424
n=5 -> 0.379   n=6 -> 0.346   n=7 -> 0.321

Testing

The base case is verified against scipy.stats.circmean and scipy.stats.circstd, the same reference-implementation approach the engine itself was built with. The mean angle is checked against a known-good circular mean, and the resultant length is checked against the identity r == exp(-circstd**2 / 2). The suite also pins a set of fixed expected values that were confirmed equal to the production engine on real chart data, so the behavior is locked without this repository depending on any engine code.

scipy is a test-only dependency. The library itself stays pure standard library.

pip install -e ".[dev]"
pytest

License

MIT. Copyright 2026 Zachary Sutton.

About

Circular statistics with a coherence measure: the group averaging core of LoveCartography

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages