This commit is contained in:
Horldsence
2026-09-12 16:58:25 +08:00
parent 1fa2264a17
commit 892451dca9
36 changed files with 8767 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
"""问题二:第二检测点选择策略。
- ``optimal.py`` :生产选点器 ``mcm26b.planner.select_measurement_point``
(保证接收盘内最坏后验半径最小化,采样启发式);
- ``baseline.py`` :"垂直 1000 m 最大交会角"朴素启发式(忽略接收保证),
以及"退到候选集 MEC 圆心"的保守基线;
- ``reference.py``:在保证接收盘内稠密网格枚举的近似最优参考;
- ``cases.py`` :随机首次示向度算例;
- ``compare.py`` :同案配对对比并落盘。
"""
from . import baseline, cases, optimal, reference
__all__ = ["optimal", "baseline", "reference", "cases"]
+71
View File
@@ -0,0 +1,71 @@
"""问题二基线实现:两个朴素但常见的对照。
- ``candidate_perp``:沿首次示向度垂直方向、距检测点约 1000 m 取点。
这是"尽量把交会角做成 90°"的教科书启发式,**但它忽略接收距离保证**:
真源可能落在首次角域的远端(≤1500 m),此时第二点距离可超过 1000 m,
第二次测量直接 ``no_signal``,几何收益落空。
- ``candidate_mec``:退到候选集最小包围圆圆心。它确实落在保证接收盘内
(ρ≤1000 时),但交会角可能接近 0°、后验几乎不收缩,代表"最保守"一端。
"""
from __future__ import annotations
import math
from mcm26b.config import ProblemConfig
from mcm26b.planner import safe_disk_radius
from project.common import Method, problem_config
from project.q2.region import first_bearing_region
PERP = Method(
key="baseline_perp",
name="垂直 1000 m 最大交会角",
kind="baseline",
note="忽略接收保证的几何启发式",
)
MEC = Method(
key="baseline_mec",
name="候选集 MEC 圆心",
kind="baseline",
note="保证接收但交会角最差的保守基线",
)
METHODS = (PERP, MEC)
def _region(case: dict, config: ProblemConfig):
return first_bearing_region(case, config)
def candidate_perp(case: dict, config: ProblemConfig | None = None) -> dict:
config = config or problem_config()
region = _region(case, config)
station = (case["station"][0], case["station"][1])
theta = math.radians(case["bearing_deg"] + 90.0)
distance = config.field.r_eff_min_m
position = (station[0] + distance * math.cos(theta),
station[1] + distance * math.sin(theta))
return {
"method": PERP.key,
"position": [round(position[0], 4), round(position[1], 4)],
"worst_posterior_radius_m": None, # 由 compare 统一评估
"move_cost_s": None,
"safe_disk_radius_m": safe_disk_radius(region, config),
"initial_radius_m": region.mec()[1],
"reason": "沿首次示向度法向 1000 m 取 90° 交会点",
}
def candidate_mec(case: dict, config: ProblemConfig | None = None) -> dict:
config = config or problem_config()
region = _region(case, config)
center, radius = region.mec()
return {
"method": MEC.key,
"position": [round(center[0], 4), round(center[1], 4)],
"worst_posterior_radius_m": None,
"move_cost_s": None,
"safe_disk_radius_m": safe_disk_radius(region, config),
"initial_radius_m": radius,
"reason": "取候选集最小包围圆圆心,保证接收但交会角可能最差",
}
+37
View File
@@ -0,0 +1,37 @@
"""问题二算例:随机首次示向度(全向源)。"""
from __future__ import annotations
import math
import random
from mcm26b.config import ProblemConfig
def random_case(seed: int, config: ProblemConfig | None = None) -> dict:
"""随机检测点 s 与示向度 θ:默认让真源与读数一致(误差 ±1° 内)。"""
config = config or ProblemConfig()
rng = random.Random(seed)
radius = config.field.omega_radius_m
# 检测点可在目标域内或域外;这里取域内,符合题目常规场景
angle_s = rng.uniform(0, 2 * math.pi)
distance_s = radius * math.sqrt(rng.random()) * 0.9
station = (distance_s * math.cos(angle_s), distance_s * math.sin(angle_s))
# 真源与检测点距离落在 [60, 1500) 且仍在 Ω 内
while True:
bearing_true = rng.uniform(0, 2 * math.pi)
source_distance = rng.uniform(60, config.field.r_eff_max_m - 1)
target = (station[0] + source_distance * math.cos(bearing_true),
station[1] + source_distance * math.sin(bearing_true))
if math.hypot(*target) <= radius:
break
error = math.radians(rng.uniform(-0.95, 0.95))
bearing_deg = math.degrees(bearing_true + error) % 360.0
return {"id": f"q2-{seed}", "station": [round(station[0], 2), round(station[1], 2)],
"bearing_deg": round(bearing_deg, 2), "half_angle_deg": 1.0,
"true_source": [round(target[0], 2), round(target[1], 2)]}
def random_cases(count: int, base_seed: int = 4200,
config: ProblemConfig | None = None) -> list[dict]:
return [random_case(base_seed + index, config) for index in range(count)]
+136
View File
@@ -0,0 +1,136 @@
"""问题二同案配对对比:最优 / 两个基线 / 网格参考,用同一评估器打分。"""
from __future__ import annotations
import math
from mcm26b.config import ProblemConfig
from mcm26b.planner import _worst_posterior_radius
from project.common import (Method, RESULTS_DIR, markdown_table, mean, ratio,
write_json, write_text)
from project.q2 import baseline, cases, optimal, reference
from project.q2.region import first_bearing_region
METHODS: tuple[Method, ...] = (optimal.METHOD, baseline.PERP, baseline.MEC,
reference.METHOD)
EVAL_WITNESS_SPACING_M = 60.0
def _evaluate(case: dict, config: ProblemConfig) -> list[dict]:
region = first_bearing_region(case, config)
station = (case["station"][0], case["station"][1])
initial_radius = region.mec()[1]
half_angle = (config.field.bearing_error_half_deg
+ config.search.bearing_rounding_margin_deg)
witnesses = region.witnesses(EVAL_WITNESS_SPACING_M)
plans = [optimal.candidate(case, config),
baseline.candidate_perp(case, config),
baseline.candidate_mec(case, config),
reference.candidate(case, config)]
rows = []
for plan in plans:
q = (plan["position"][0], plan["position"][1])
guarantee_distance = max(math.dist(q, vertex) for vertex in region.polygon)
worst = _worst_posterior_radius(
region, q, witnesses, half_angle,
config.field.near_radius_m, config.search.polygon_sides)
move_cost = math.dist(station, q) / config.cost.speed_mps
rows.append({
"case_id": case["id"],
"method": plan["method"],
"initial_radius_m": round(initial_radius, 4),
"q": [round(q[0], 2), round(q[1], 2)],
"guaranteed_reception": bool(guarantee_distance
<= config.field.r_eff_min_m + 1e-6),
"guarantee_distance_m": round(guarantee_distance, 3),
"worst_posterior_radius_m": round(worst, 4),
"improvement_m": round(initial_radius - worst, 4),
"final_ratio": round(worst / initial_radius, 4) if initial_radius > 0 else None,
"move_cost_s": round(move_cost, 4),
"reason": plan["reason"],
})
return rows
def _aggregate(rows: list[dict], labels: list[str]) -> dict:
summary: dict[str, dict] = {}
initial = mean(r["initial_radius_m"] for r in rows) if rows else 0.0
for label in labels:
part = [r for r in rows if r["method"] == label]
if not part:
continue
summary[label] = {
"cases": len(part),
"guaranteed_rate": ratio(sum(r["guaranteed_reception"] for r in part),
len(part)),
"worst_posterior_mean_m": mean(r["worst_posterior_radius_m"] for r in part),
"worst_posterior_median_m": sorted(
r["worst_posterior_radius_m"] for r in part)[len(part) // 2],
"improvement_mean_m": mean(r["improvement_m"] for r in part),
"final_ratio_mean": mean(r["final_ratio"] for r in part
if r["final_ratio"] is not None),
"move_cost_mean_s": mean(r["move_cost_s"] for r in part),
}
summary["_initial_radius_mean_m"] = initial
return summary
def _render(rows: list[dict], aggregate: dict, labels: list[str]) -> str:
headers = ["算例", "方法", "初始 ρ(m)", "第二点 q", "保证接收",
"q 到 K₁ 最远(m)", "最坏后验 ρ'(m)", "收缩(m)", "移动(s)"]
body = []
order = {label: index for index, label in enumerate(labels)}
for row in sorted(rows, key=lambda r: (r["case_id"], order.get(r["method"], 99))):
body.append([
row["case_id"], row["method"], _f(row["initial_radius_m"]),
f"({row['q'][0]:.0f},{row['q'][1]:.0f})",
"是" if row["guaranteed_reception"] else "否",
_f(row["guarantee_distance_m"]), _f(row["worst_posterior_radius_m"]),
_f(row["improvement_m"]), _f(row["move_cost_s"]),
])
text = ["# 问题二:第二检测点选择对比", "",
"初始 ρ 为首次示向度候选集 K₁ 的最小包围半径;最坏后验按"
"目标边界采样 × 读数区间端点统一评估。", "",
markdown_table(headers, body), "", "## 逐方法汇总", ""]
agg_headers = ["方法", "保证接收率", "最坏后验均值(m)", "中位(m)",
"平均收缩(m)", "终值比 ρ'/ρ", "移动均值(s)"]
agg_rows = []
for label in labels:
s = aggregate.get(label)
if not s:
continue
agg_rows.append([label, f"{s['guaranteed_rate']*100:.1f}%",
_f(s["worst_posterior_mean_m"]),
_f(s["worst_posterior_median_m"]),
_f(s["improvement_mean_m"]),
_f(s["final_ratio_mean"], 3),
_f(s["move_cost_mean_s"])])
text.append(markdown_table(agg_headers, agg_rows))
text.append("")
text.append(f"初始 ρ 均值:{aggregate['_initial_radius_mean_m']:.2f} m")
return "\n".join(text) + "\n"
def _f(value, digits: int = 2) -> str:
if value is None:
return "—"
if isinstance(value, bool):
return "是" if value else "否"
if isinstance(value, float):
return f"{value:.{digits}f}"
return str(value)
def run(quick: bool = False) -> dict:
config = ProblemConfig()
case_list = cases.random_cases(8 if quick else 30, config=config)
rows = [row for case in case_list for row in _evaluate(case, config)]
labels = [m.key for m in METHODS]
aggregate = _aggregate(rows, labels)
payload = {"question": 2, "methods": [m.__dict__ for m in METHODS],
"cases": case_list, "rows": rows, "aggregate": aggregate}
write_json(RESULTS_DIR / "q2.json", payload)
write_text(RESULTS_DIR / "q2.md", _render(rows, aggregate, labels))
return payload
+42
View File
@@ -0,0 +1,42 @@
"""问题二最优实现:保证接收盘内的最坏后验半径最小化选点(生产实现)。
复用 ``mcm26b.planner.select_measurement_point``:
1. 在"保证接收盘" B(mec_center, 1000-ρ) 内环形采样候选点;
2. 对每个候选,用"目标边界点 × 读数区间端点(+near 分支)"估计
最坏后验最小包围半径,并以移动时间同分决胜;
3. 选定后按 1/4 间距加密复核,返回位置、后验半径与理由。
采样是启发式(不是连续问题全局最优证明),但保证接收是严格的。
"""
from __future__ import annotations
from mcm26b.config import ProblemConfig
from mcm26b.planner import safe_disk_radius, select_measurement_point
from project.common import Method, problem_config
from project.q2.region import first_bearing_region
METHOD = Method(
key="optimal",
name="保证接收盘内最小最坏后验半径",
kind="optimal",
note="生产实现 mcm26b.planner.select_measurement_point",
)
def candidate(case: dict, config: ProblemConfig | None = None) -> dict:
config = config or problem_config()
region = first_bearing_region(case, config)
station = (case["station"][0], case["station"][1])
plan = select_measurement_point(region, station, config)
return {
"method": METHOD.key,
"position": [round(plan.position[0], 4), round(plan.position[1], 4)],
"worst_posterior_radius_m": plan.worst_posterior_radius_m,
"move_cost_s": plan.move_cost_s,
"safe_disk_radius_m": safe_disk_radius(region, config),
"initial_radius_m": region.mec()[1],
"reason": plan.reason,
}
+67
View File
@@ -0,0 +1,67 @@
"""问题二近似最优参考:在保证接收盘内做稠密网格枚举。
目标函数与生产实现一致(最坏后验包围半径 + 移动时间同分决胜),但候选点
取自**均匀网格**而非环形采样族,因此可用来检验生产采样是否接近该目标下的
网格最优。注意这是"同一目标下的网格参考",不是连续问题的严格全局最优。
"""
from __future__ import annotations
import math
from mcm26b.config import ProblemConfig
from mcm26b.planner import (_worst_posterior_radius, safe_disk_radius)
from project.common import Method, problem_config
from project.q2.region import first_bearing_region
METHOD = Method(
key="reference",
name="保证接收盘内网格枚举",
kind="reference",
note="同一目标下的稠密网格参考,非连续全局最优",
)
GRID_SIDES = 9
WITNESS_SPACING_M = 150.0
def candidate(case: dict, config: ProblemConfig | None = None) -> dict:
config = config or problem_config()
region = first_bearing_region(case, config)
station = (case["station"][0], case["station"][1])
center = region.mec()[0]
safe = safe_disk_radius(region, config)
if safe < 0:
return _payload(center, region, config, station, "无保证接收盘:退回 MEC 圆心")
field, search = config.field, config.search
half_angle = field.bearing_error_half_deg + search.bearing_rounding_margin_deg
witnesses = region.witnesses(WITNESS_SPACING_M)
step = 2.0 * safe / (GRID_SIDES - 1)
best_key, best_point, best_worst = None, center, None
for i in range(GRID_SIDES):
for j in range(GRID_SIDES):
point = (center[0] - safe + i * step, center[1] - safe + j * step)
if math.dist(point, center) > safe + 1e-9:
continue
worst = _worst_posterior_radius(region, point, witnesses, half_angle,
field.near_radius_m, search.polygon_sides)
move = math.dist(station, point) / config.cost.speed_mps
key = (round(worst, 6), round(move, 6))
if best_key is None or key < best_key:
best_key, best_point, best_worst = key, point, worst
return _payload(best_point, region, config, station,
f"安全盘网格 {GRID_SIDES}×{GRID_SIDES} 取最坏后验最小",
worst_value=best_worst)
def _payload(point, region, config, station, reason, worst_value=None) -> dict:
return {
"method": METHOD.key,
"position": [round(point[0], 4), round(point[1], 4)],
"worst_posterior_radius_m": worst_value,
"move_cost_s": math.dist(station, point) / config.cost.speed_mps,
"safe_disk_radius_m": safe_disk_radius(region, config),
"initial_radius_m": region.mec()[1],
"reason": reason,
}
+24
View File
@@ -0,0 +1,24 @@
"""问题二候选集 K1 的统一构造(三个方法共用同一输入,保证配对公平)。
K1 = Ω ∩ W(s, θ ± δ_eff) ∩ B(s, 1500),δ_eff = 题设 1° + 示向度舍入裕量。
生产实现用"圆盘外切多边形 ∩ 半平面"的保守外包(``CandidateRegion``),
圆盘用外切多边形严格外包,保证真源不丢。
"""
from __future__ import annotations
from mcm26b.belief import CandidateRegion
from mcm26b.config import ProblemConfig, Vec2
from project.common import problem_config
def first_bearing_region(case: dict,
config: ProblemConfig | None = None) -> CandidateRegion:
config = config or problem_config()
field, search = config.field, config.search
half_angle = field.bearing_error_half_deg + search.bearing_rounding_margin_deg
station: Vec2 = (case["station"][0], case["station"][1])
region = CandidateRegion.initial(station, config)
region.clip_wedge(station, case["bearing_deg"], half_angle)
return region