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.
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.0Averaging 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
ris 1. - When the positions are tight,
rstays high. - When they scatter around the circle, the vectors point in different
directions and partly cancel, so
rdrops. - When they cancel completely, say two positions exactly opposite each other,
ris 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.
pip install chartmeanRequires Python 3.11 or 3.12.
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)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. Checkrbefore trustingmean.
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: therat or above which a point is marked coherent. Defaults to 0.6. Passscaled_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 whenris at or above the threshold. The comparison is inclusive.count: how many charts supplied that point.
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
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]"
pytestMIT. Copyright 2026 Zachary Sutton.