Add rounded model-vote baseline to probability distribution

This commit is contained in:
2569718930@qq.com
2026-04-17 00:48:12 +08:00
parent 19169e4bdb
commit c346fae351
2 changed files with 233 additions and 7 deletions
@@ -1001,6 +1001,87 @@
border: 1px solid rgba(251, 113, 133, 0.26);
}
.root :global(.prob-model-vote) {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 8px;
background: rgba(15, 23, 42, 0.34);
}
.root :global(.prob-model-vote-head) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.root :global(.prob-model-vote-head span) {
color: var(--text-primary);
font-size: 12px;
font-weight: 800;
}
.root :global(.prob-model-vote-head em) {
color: var(--text-muted);
font-size: 11px;
font-style: normal;
}
.root :global(.prob-model-vote-grid) {
display: grid;
gap: 6px;
}
.root :global(.prob-model-vote-row) {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.root :global(.prob-model-vote-row > span) {
color: var(--text-primary);
font-size: 12px;
font-weight: 800;
text-align: right;
}
.root :global(.prob-model-vote-track) {
position: relative;
height: 18px;
overflow: hidden;
border-radius: 6px;
background: rgba(148, 163, 184, 0.1);
}
.root :global(.prob-model-vote-fill) {
height: 100%;
border-radius: 6px;
background: linear-gradient(90deg, rgba(20, 184, 166, 0.62), rgba(34, 211, 238, 0.88));
}
.root :global(.prob-model-vote-track strong) {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
color: var(--text-primary);
font-size: 11px;
line-height: 1;
text-shadow: 0 1px 2px rgba(2, 6, 23, 0.85);
}
.root :global(.prob-model-vote p) {
margin: 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
}
/* ── Model Bars ── */
.root :global(.model-bars) {
display: flex;
@@ -1125,13 +1206,6 @@
border-radius: 6px;
background: linear-gradient(90deg, rgba(14, 165, 233, 0.72), rgba(34, 211, 238, 0.92));
transition: width 0.6s ease-out;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
font-size: 11px;
font-weight: 600;
color: white;
}
.root :global(.model-bar-fill.deb) {
@@ -1139,6 +1213,28 @@
box-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
}
.root :global(.model-bar-value) {
position: absolute;
top: 50%;
right: 8px;
z-index: 3;
transform: translateY(-50%);
color: var(--text-primary);
font-size: 11px;
font-weight: 800;
line-height: 1;
max-width: calc(100% - 12px);
overflow: visible;
pointer-events: none;
text-align: right;
text-shadow: 0 1px 2px rgba(2, 6, 23, 0.9);
white-space: nowrap;
}
.root :global(.model-bar-value.deb) {
color: #ecfeff;
}
.root :global(.model-deb-line) {
position: absolute;
top: 0;
@@ -174,6 +174,92 @@ function formatModelMetaLine(
return parts.join(" · ");
}
function normalizeModelNameForVote(name: string) {
return String(name || "")
.trim()
.toLowerCase()
.replace(/[\s_/-]/g, "");
}
function getModelVoteFamily(name: string) {
const normalized = normalizeModelNameForVote(name);
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
if (normalized === "ecmwf") return "ecmwf_ifs";
return normalized || name;
}
function getModelVotePriority(name: string) {
const normalized = normalizeModelNameForVote(name);
return (
{
icond2: 40,
iconeu: 30,
icon: 20,
hrdps: 40,
rdps: 35,
gdps: 30,
gem: 20,
ecmwfaifs: 30,
ecmwf: 30,
gfs: 30,
jma: 30,
mgm: 45,
nws: 45,
openmeteo: 15,
}[normalized] || 10
);
}
function getRoundedModelVoteDistribution(
detail: CityDetail,
targetDate?: string | null,
) {
const view = getModelView(detail, targetDate);
const representatives = new Map<
string,
{ name: string; priority: number; value: number }
>();
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
const normalized = normalizeModelNameForVote(name);
if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
const value = Number(rawValue);
if (!Number.isFinite(value)) return;
const family = getModelVoteFamily(name);
const priority = getModelVotePriority(name);
const current = representatives.get(family);
if (!current || priority > current.priority) {
representatives.set(family, { name, priority, value });
}
});
const bucketMap = new Map<number, { count: number; models: string[] }>();
representatives.forEach(({ name, value }) => {
const rounded = Math.round(value);
const row = bucketMap.get(rounded) || { count: 0, models: [] };
row.count += 1;
row.models.push(name);
bucketMap.set(rounded, row);
});
const total = representatives.size;
const rows = Array.from(bucketMap.entries())
.map(([value, row]) => ({
count: row.count,
models: row.models,
percent: total > 0 ? row.count / total : 0,
value,
}))
.sort((a, b) => b.count - a.count || b.value - a.value);
return {
rows,
total,
};
}
function getMarketNoPrice(scan?: MarketScan | null) {
if (scan?.no_buy != null) {
const direct = Number(scan.no_buy);
@@ -494,6 +580,10 @@ export function ProbabilityDistribution({
const marketYesText = toPercent(marketYesPrice);
const marketNoText = toPercent(marketNoPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const modelVoteView = useMemo(
() => getRoundedModelVoteDistribution(detail, targetDate),
[detail, targetDate],
);
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
@@ -549,6 +639,46 @@ export function ProbabilityDistribution({
: `市场概率(该温度桶): ${marketYesText}`}
</div>
)}
{modelVoteView.rows.length > 0 && (
<div className="prob-model-vote">
<div className="prob-model-vote-head">
<span>
{locale === "en-US"
? "Rounded model-vote baseline"
: "模型四舍五入票数基线"}
</span>
<em>
{locale === "en-US"
? `${modelVoteView.total} sources after family dedup`
: `家族去重后 ${modelVoteView.total} 个来源`}
</em>
</div>
<div className="prob-model-vote-grid">
{modelVoteView.rows.slice(0, 4).map((row) => (
<div key={row.value} className="prob-model-vote-row">
<span>
{row.value}
{detail.temp_symbol}
</span>
<div className="prob-model-vote-track">
<div
className="prob-model-vote-fill"
style={{ width: `${Math.max(row.percent * 100, 8)}%` }}
/>
<strong>
{row.count}/{modelVoteView.total}
</strong>
</div>
</div>
))}
</div>
<p>
{locale === "en-US"
? "This is a transparent model-count baseline, not the calibrated settlement probability."
: "这是透明模型票数基线,不等同于经过实测下限与历史 MAE 校准后的结算概率。"}
</p>
</div>
)}
{useMarketTopBuckets ? (
sortedMarketTopBuckets.map((bucket, index) => {
const probability = Math.round(