setuhq 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. setuhq/__init__.py +10 -0
  2. setuhq/aggregate.py +208 -0
  3. setuhq/agreement.py +83 -0
  4. setuhq/app.py +3163 -0
  5. setuhq/batch.py +168 -0
  6. setuhq/benchmark.py +147 -0
  7. setuhq/benchmark_toi.py +954 -0
  8. setuhq/brand.py +101 -0
  9. setuhq/calibrate.py +196 -0
  10. setuhq/catalogue.py +653 -0
  11. setuhq/cli.py +630 -0
  12. setuhq/codenote.py +109 -0
  13. setuhq/config.py +159 -0
  14. setuhq/contrib.py +592 -0
  15. setuhq/dashboard.py +415 -0
  16. setuhq/data/claret2017_tess_quadratic.json +1 -0
  17. setuhq/data/secondary_calibration.json +2114 -0
  18. setuhq/dipslugs.py +112 -0
  19. setuhq/discover.py +372 -0
  20. setuhq/docket.py +1644 -0
  21. setuhq/dvtests.py +639 -0
  22. setuhq/ephemeris.py +84 -0
  23. setuhq/everything.py +580 -0
  24. setuhq/exofop.py +265 -0
  25. setuhq/fonts/OFL-geist-mono.txt +93 -0
  26. setuhq/fonts/OFL-geist.txt +93 -0
  27. setuhq/fonts/OFL-unbounded.txt +93 -0
  28. setuhq/fonts/files/geist-gyByhwUxId8gMEwSGFWNOITddY4.woff2 +0 -0
  29. setuhq/fonts/files/geist-gyByhwUxId8gMEwcGFWNOITd.woff2 +0 -0
  30. setuhq/fonts/files/geistmono-or3nQ6H-1_WfwkMZI_qYFrcdmhHkjko.woff2 +0 -0
  31. setuhq/fonts/files/geistmono-or3nQ6H-1_WfwkMZI_qYFrkdmhHkjkotbA.woff2 +0 -0
  32. setuhq/fonts/files/unbounded-Yq6W-LOTXCb04q32xlpwtcZfvRIkSYZH.woff2 +0 -0
  33. setuhq/fonts/files/unbounded-Yq6W-LOTXCb04q32xlpwu8ZfvRIkSQ.woff2 +0 -0
  34. setuhq/fonts/fonts.css +131 -0
  35. setuhq/galaxy.py +1639 -0
  36. setuhq/injection.py +124 -0
  37. setuhq/leaderboard.py +639 -0
  38. setuhq/learn.py +162 -0
  39. setuhq/lightcurve.py +55 -0
  40. setuhq/loaders.py +269 -0
  41. setuhq/mcp_server.py +171 -0
  42. setuhq/methods.py +3240 -0
  43. setuhq/mission.py +352 -0
  44. setuhq/names.py +156 -0
  45. setuhq/objects.py +36 -0
  46. setuhq/pipeline.py +874 -0
  47. setuhq/pixel.py +904 -0
  48. setuhq/plate.py +733 -0
  49. setuhq/preprocess.py +183 -0
  50. setuhq/py.typed +0 -0
  51. setuhq/render.py +37 -0
  52. setuhq/report.py +357 -0
  53. setuhq/reviewers/__init__.py +38 -0
  54. setuhq/reviewers/base.py +68 -0
  55. setuhq/reviewers/claude.py +102 -0
  56. setuhq/reviewers/claude_code.py +107 -0
  57. setuhq/reviewers/gemini.py +101 -0
  58. setuhq/reviewers/http.py +155 -0
  59. setuhq/reviewers/openai_compat.py +148 -0
  60. setuhq/reviewers/sentinel.py +195 -0
  61. setuhq/reviewers/simulated.py +105 -0
  62. setuhq/reviewers/task.py +134 -0
  63. setuhq/routes.py +231 -0
  64. setuhq/search.py +421 -0
  65. setuhq/site.py +915 -0
  66. setuhq/starsearch.py +278 -0
  67. setuhq/stellar.py +71 -0
  68. setuhq/store.py +157 -0
  69. setuhq/story.py +316 -0
  70. setuhq/stubs.py +152 -0
  71. setuhq/subjects.py +80 -0
  72. setuhq/sweep.py +907 -0
  73. setuhq/tools.py +637 -0
  74. setuhq/transitfit.py +499 -0
  75. setuhq/validate.py +438 -0
  76. setuhq/variables.py +650 -0
  77. setuhq/vet.py +142 -0
  78. setuhq-0.1.0.dist-info/METADATA +140 -0
  79. setuhq-0.1.0.dist-info/RECORD +85 -0
  80. setuhq-0.1.0.dist-info/WHEEL +5 -0
  81. setuhq-0.1.0.dist-info/entry_points.txt +5 -0
  82. setuhq-0.1.0.dist-info/licenses/LICENSE +661 -0
  83. setuhq-0.1.0.dist-info/licenses/LICENSING.md +120 -0
  84. setuhq-0.1.0.dist-info/licenses/NOTICE +130 -0
  85. setuhq-0.1.0.dist-info/top_level.txt +1 -0
setuhq/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # SPDX-FileCopyrightText: 2026 Ashforde OÜ
3
+ """SetuHQ: an open-source transit search in the style of Planet Hunters CHEOPS.
4
+
5
+ Volunteers are replaced by a reviewer panel. The panel can include simulated
6
+ volunteers (deterministic detectors) and Sentinels (vision models, Claude by default, given
7
+ the same task). Consensus is weighted by skill measured on injected transits.
8
+ """
9
+
10
+ __version__ = "0.1.0"
setuhq/aggregate.py ADDED
@@ -0,0 +1,208 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # SPDX-FileCopyrightText: 2026 Ashforde OÜ
3
+ """Consensus: turn many independent classifications into ranked events.
4
+
5
+ This follows the Planet Hunters idea. Marks from different reviewers that land
6
+ on the same dip are clustered in time. Each cluster is scored by the share of
7
+ the panel's skill-weighted reviewers who marked it. A cluster counts only if
8
+ the subject is also labelled ``potential_planet`` by enough of the weighted panel.
9
+
10
+ Clustering is single-linkage on intervals: marks are taken in order of start
11
+ time, and a mark joins the current cluster when its interval overlaps the
12
+ cluster's interval (the union of its marks so far) or its centre is within
13
+ ``gap_days`` of the previous mark's centre. A wide mark that covers a dip
14
+ therefore counts even when its centre sits off the dip's centre.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict
20
+ from dataclasses import dataclass
21
+ from statistics import median
22
+
23
+ from setuhq.reviewers.base import POTENTIAL_PLANET, Classification
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Consensus:
28
+ subject_id: str
29
+ t_start: float
30
+ t_end: float
31
+ score: float
32
+ n_reviewers: int
33
+ reviewers: tuple[str, ...]
34
+ planet_fraction: float
35
+ # The marks that counted, one per reviewer: (reviewer_id, t_start, t_end).
36
+ marks: tuple = ()
37
+
38
+ @property
39
+ def center(self) -> float:
40
+ return 0.5 * (self.t_start + self.t_end)
41
+
42
+
43
+ def cluster_marks(entries, gap_days: float) -> list[list[tuple[str, float, float]]]:
44
+ """Group ``(reviewer_id, t_start, t_end)`` entries into time clusters. Input order does not matter.
45
+
46
+ A mark joins the current cluster when its interval overlaps the cluster's
47
+ interval, or when its centre is within ``gap_days`` of the previous mark's centre.
48
+ """
49
+ ordered = sorted(entries, key=lambda e: (e[1], e[2]))
50
+ clusters: list[list[tuple[str, float, float]]] = []
51
+ hi = last_center = None
52
+ for entry in ordered:
53
+ t_start, t_end = entry[1], entry[2]
54
+ center = 0.5 * (t_start + t_end)
55
+ joins = hi is not None and (t_start <= hi or abs(center - last_center) <= gap_days)
56
+ if joins:
57
+ clusters[-1].append(entry)
58
+ hi = max(hi, t_end)
59
+ else:
60
+ clusters.append([entry])
61
+ hi = t_end
62
+ last_center = center
63
+ return clusters
64
+
65
+
66
+ def _one_mark_per_reviewer(cluster) -> dict[str, tuple[float, float]]:
67
+ """Each reviewer's mark in a cluster: the one whose centre is nearest the cluster's median centre."""
68
+ middle = median(0.5 * (a + b) for _, a, b in cluster)
69
+ chosen: dict[str, tuple[float, float]] = {}
70
+ for reviewer_id, t_start, t_end in cluster:
71
+ best = chosen.get(reviewer_id)
72
+ if best is None or abs(0.5 * (t_start + t_end) - middle) < abs(0.5 * sum(best) - middle):
73
+ chosen[reviewer_id] = (t_start, t_end)
74
+ return chosen
75
+
76
+
77
+ def aggregate(
78
+ classifications: list[Classification],
79
+ weights: dict[str, float] | None = None,
80
+ *,
81
+ gap_days: float = 0.05,
82
+ min_score: float = 0.5,
83
+ min_planet_fraction: float = 0.5,
84
+ ) -> list[Consensus]:
85
+ """Return consensus events that pass both thresholds, grouped by subject and ordered by score."""
86
+ weights = weights or {}
87
+ by_subject: dict[str, list[Classification]] = defaultdict(list)
88
+ for classification in classifications:
89
+ by_subject[classification.subject_id].append(classification)
90
+
91
+ results: list[Consensus] = []
92
+ for subject_id in sorted(by_subject):
93
+ panel = _latest_per_reviewer(by_subject[subject_id])
94
+ total_weight = sum(_weight(weights, c.reviewer_id) for c in panel.values())
95
+ if total_weight <= 0:
96
+ continue
97
+ planet_weight = sum(_weight(weights, c.reviewer_id) for c in panel.values() if c.label == POTENTIAL_PLANET)
98
+ planet_fraction = planet_weight / total_weight
99
+ if planet_fraction < min_planet_fraction:
100
+ continue
101
+
102
+ entries = [(c.reviewer_id, m.t_start, m.t_end) for c in panel.values() for m in c.marks]
103
+ for cluster in cluster_marks(entries, gap_days):
104
+ first_mark = _one_mark_per_reviewer(cluster)
105
+ score = sum(_weight(weights, r) for r in first_mark) / total_weight
106
+ if score < min_score:
107
+ continue
108
+ results.append(
109
+ Consensus(
110
+ subject_id=subject_id,
111
+ t_start=median(t for t, _ in first_mark.values()),
112
+ t_end=median(t for _, t in first_mark.values()),
113
+ score=score,
114
+ n_reviewers=len(first_mark),
115
+ reviewers=tuple(sorted(first_mark)),
116
+ planet_fraction=planet_fraction,
117
+ marks=tuple((r, a, b) for r, (a, b) in sorted(first_mark.items())),
118
+ )
119
+ )
120
+ return sorted(_merge_duplicates(results, gap_days), key=lambda c: c.score, reverse=True)
121
+
122
+
123
+ def counting_rule(gap_days: float, min_score: float) -> str:
124
+ """The rule that decides which marks count toward a consensus event, in words."""
125
+ return (
126
+ f"marks are taken in order of start time and a mark joins the cluster when its interval overlaps the "
127
+ f"cluster's interval or its centre lies within {gap_days:g} d of the previous mark's centre (interval "
128
+ f"single-linkage clustering, aggregate.py); each reviewer counts once, with the mark nearest the cluster's "
129
+ f"median centre; a cluster whose weighted share is below {min_score:g} is dropped, and of two clusters on one "
130
+ f"dip only the stronger is kept"
131
+ )
132
+
133
+
134
+ def audit_marks(event: Consensus, classifications: list[Classification], gap_days: float) -> list[dict]:
135
+ """For one event, every reviewer's marks over its window and whether each counted.
136
+
137
+ This only reports the rule in :func:`aggregate`; it does not change it. A
138
+ mark counts when it is the reviewer's mark in the event's cluster
139
+ (``event.marks``). ``overlap_frac`` is the share of the consensus window
140
+ [t_start, t_end] the mark covers. ``centre_gap_days`` is the distance from
141
+ the mark's centre to the nearest centre of a counted mark (another one, for
142
+ a counted mark). A mark that overlaps no counted mark and whose centre is
143
+ more than ``gap_days`` from every counted centre fell in a separate cluster.
144
+ """
145
+ panel = _latest_per_reviewer([c for c in classifications if c.subject_id == event.subject_id])
146
+ counted = {r: (a, b) for r, a, b in event.marks}
147
+ centres = {r: 0.5 * (a + b) for r, (a, b) in counted.items()}
148
+ width = event.t_end - event.t_start
149
+ out = []
150
+ for reviewer_id in sorted(panel):
151
+ rows = []
152
+ for m in panel[reviewer_id].marks:
153
+ overlap = max(0.0, min(m.t_end, event.t_end) - max(m.t_start, event.t_start))
154
+ is_counted = counted.get(reviewer_id) == (m.t_start, m.t_end)
155
+ if overlap <= 0 and not is_counted:
156
+ continue
157
+ centre = 0.5 * (m.t_start + m.t_end)
158
+ others = [c for r, c in centres.items() if not (is_counted and r == reviewer_id)]
159
+ rows.append({
160
+ "t_start": m.t_start,
161
+ "t_end": m.t_end,
162
+ "overlap_frac": overlap / width if width > 0 else None,
163
+ "centre_gap_days": min(abs(centre - c) for c in others) if others else None,
164
+ "counted": is_counted,
165
+ })
166
+ out.append({
167
+ "reviewer_id": reviewer_id,
168
+ "counted": reviewer_id in counted,
169
+ # The counted mark's overlap when one counted, else the largest overlap of any mark.
170
+ "overlap_frac": (next((r["overlap_frac"] for r in rows if r["counted"]), None) if reviewer_id in counted
171
+ else max((r["overlap_frac"] or 0.0 for r in rows), default=0.0)) if width > 0 else None,
172
+ "marks": rows,
173
+ })
174
+ return out
175
+
176
+
177
+ def _merge_duplicates(events: list[Consensus], gap_days: float) -> list[Consensus]:
178
+ """Keep the strongest of any two events in one subject whose centres are close.
179
+
180
+ Two events are the same dip when their centres lie within half the sum of
181
+ their durations plus the clustering gap. Clustering alone can split one dip
182
+ when reviewers' boundaries disagree, and this step puts it back together.
183
+ """
184
+ kept: list[Consensus] = []
185
+ # Ties go to the event with more reviewers, then to the wider one: the more complete dip wins.
186
+ ranked = sorted(events, key=lambda c: (c.score, c.n_reviewers, c.t_end - c.t_start), reverse=True)
187
+ for event in ranked:
188
+ duplicate = any(
189
+ other.subject_id == event.subject_id
190
+ and abs(other.center - event.center)
191
+ <= 0.5 * ((other.t_end - other.t_start) + (event.t_end - event.t_start)) + gap_days
192
+ for other in kept
193
+ )
194
+ if not duplicate:
195
+ kept.append(event)
196
+ return kept
197
+
198
+
199
+ def _latest_per_reviewer(classifications: list[Classification]) -> dict[str, Classification]:
200
+ """One classification per reviewer per subject. A later duplicate replaces an earlier one."""
201
+ panel: dict[str, Classification] = {}
202
+ for classification in classifications:
203
+ panel[classification.reviewer_id] = classification
204
+ return panel
205
+
206
+
207
+ def _weight(weights: dict[str, float], reviewer_id: str) -> float:
208
+ return float(weights.get(reviewer_id, 1.0))
setuhq/agreement.py ADDED
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # SPDX-FileCopyrightText: 2026 Ashforde OÜ
3
+ """How independent are the Sentinels? Pairwise agreement (φ) on the same stars.
4
+
5
+ Two reviewers that share a model make correlated errors and count as one voter, not two:
6
+ on the stated 40-star subsample of the real-TOI benchmark the two Claude personas agree with
7
+ φ = 0.89. Sentinels on different providers may disagree more, which is what makes a panel
8
+ worth having. This module measures it, from per-star outcomes only:
9
+
10
+ hits = {star: {reviewer_id: True/False}} # did the reviewer mark the TOI's dip as a planet
11
+
12
+ φ is the Pearson correlation of two binary outcomes (the Matthews coefficient):
13
+
14
+ φ = (n11·n00 − n10·n01) / sqrt((n11+n10)(n01+n00)(n11+n01)(n10+n00))
15
+
16
+ It is None when either reviewer gave the same answer on every star (the denominator is zero).
17
+ Only stars both reviewers answered are compared. ``scripts/benchmark_real_tois.py sentinels``
18
+ collects the outcomes; its ``report`` step writes the table.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from itertools import combinations
24
+
25
+
26
+ def phi(pairs: list[tuple[bool, bool]]) -> dict:
27
+ """Agreement of two reviewers over paired binary outcomes: counts, fraction agreeing and φ."""
28
+ n11 = sum(1 for a, b in pairs if a and b)
29
+ n00 = sum(1 for a, b in pairs if not a and not b)
30
+ n10 = sum(1 for a, b in pairs if a and not b)
31
+ n01 = sum(1 for a, b in pairs if not a and b)
32
+ den = ((n11 + n10) * (n01 + n00) * (n11 + n01) * (n10 + n00)) ** 0.5
33
+ return {"n": len(pairs), "agree": n11 + n00, "n11": n11, "n10": n10, "n01": n01, "n00": n00,
34
+ "phi": (n11 * n00 - n10 * n01) / den if den else None}
35
+
36
+
37
+ def pairwise(hits: dict[str, dict[str, bool]], reviewers: list[str] | None = None,
38
+ identities: dict[str, dict] | None = None) -> list[dict]:
39
+ """φ for every pair of reviewers, over the stars both answered.
40
+
41
+ ``identities`` ({reviewer: {"provider": ..., "model": ...}}) labels each pair as the same model
42
+ (two personas of one model: expect high φ) or different providers/models (the diversity being measured).
43
+ """
44
+ ids = reviewers or sorted({rid for per in hits.values() for rid in per})
45
+ identities = identities or {}
46
+ out = []
47
+ for a, b in combinations(ids, 2):
48
+ pairs = [(per[a], per[b]) for per in hits.values() if a in per and b in per]
49
+ ia, ib = identities.get(a) or {}, identities.get(b) or {}
50
+ same = bool(ia) and bool(ib) and (ia.get("provider"), ia.get("model")) == (ib.get("provider"), ib.get("model"))
51
+ out.append({"a": a, "b": b, **phi(pairs),
52
+ "same_model": same if ia and ib else None,
53
+ "a_provider": ia.get("provider"), "a_model": ia.get("model"),
54
+ "b_provider": ib.get("provider"), "b_model": ib.get("model")})
55
+ return out
56
+
57
+
58
+ def constant_reviewers(hits: dict[str, dict[str, bool]], reviewers: list[str]) -> list[str]:
59
+ """Reviewers that gave the same answer on every star they answered (or answered none). φ with them is
60
+ undefined, so they carry no information about independence and are left out of the voter count."""
61
+ out = []
62
+ for rid in reviewers:
63
+ answers = {per[rid] for per in hits.values() if rid in per}
64
+ if len(answers) < 2:
65
+ out.append(rid)
66
+ return out
67
+
68
+
69
+ def effective_voters(table: list[dict], reviewers: list[str], exclude: list[str] = ()) -> float | None:
70
+ """A rough count of independent voters: k / (1 + (k − 1)·mean φ), with φ < 0 treated as 0.
71
+
72
+ k reviewers whose outcomes all correlate at φ act like this many independent ones (the design-effect
73
+ formula). ``exclude`` (:func:`constant_reviewers`) is left out of k and of the pairs, so k counts only
74
+ reviewers whose every pair has a φ. None when fewer than two remain, or a remaining pair has no φ
75
+ (no stars in common).
76
+ """
77
+ keep = [r for r in reviewers if r not in set(exclude)]
78
+ pairs = [p for p in table if p["a"] in keep and p["b"] in keep]
79
+ k = len(keep)
80
+ if k < 2 or not pairs or any(p["phi"] is None for p in pairs):
81
+ return None
82
+ mean = sum(max(0.0, p["phi"]) for p in pairs) / len(pairs)
83
+ return k / (1 + (k - 1) * mean)