匀速直线轨迹
先按起点和终点做等时间间隔的线性插值,再基于离散速度计算作用量。
from __future__ import annotations import argparse import html import random from pathlib import Path from typing import Dict, List, Sequence from PIL import Image, ImageDraw, ImageFont import yaml def load_input(input_path: Path) -> Dict[str, object]: with input_path.open("r", encoding="utf-8") as file: data = yaml.safe_load(file) or {} defaults = { "m": 1.0, "xa": 0.0, "ta": 0.0, "xb": 20.0, "tb": 10.0, "N": 100, "xmax": 1.0, "seed": None, } defaults.update(data) return defaults def parse_xmax_values(raw_value: object) -> List[float]: if isinstance(raw_value, (int, float)): return [float(raw_value)] if isinstance(raw_value, str): normalized = raw_value.replace(",", " ") values = [float(part) for part in normalized.split() if part] if values: return values if isinstance(raw_value, list): values = [float(item) for item in raw_value] if values: return values raise ValueError("xmax must be a number, a YAML list, or a space-separated string of numbers.") def build_straight_path(xa: float, xb: float, n_points: int) -> List[float]: if n_points < 2: raise ValueError("N must be at least 2.") return [xa + (xb - xa) * step / (n_points - 1) for step in range(n_points)] def build_noise_profile(n_points: int, seed: object) -> List[float]: if n_points < 2: raise ValueError("N must be at least 2.") rng = random.Random(seed) return [rng.uniform(-1.0, 1.0) for _ in range(n_points - 2)] def build_random_path(straight_path: Sequence[float], xmax: float, noise_profile: Sequence[float]) -> List[float]: if len(straight_path) < 2: raise ValueError("N must be at least 2.") if len(noise_profile) != len(straight_path) - 2: raise ValueError("Noise profile length must match the number of interior points.") path = [straight_path[0]] for base_x, noise in zip(straight_path[1:-1], noise_profile): path.append(base_x + noise * xmax) path.append(straight_path[-1]) return path def kinetic_action(path: Sequence[float], mass: float, dt: float) -> float: action = 0.0 for left, right in zip(path[:-1], path[1:]): velocity = (right - left) / dt kinetic_energy = 0.5 * mass * velocity * velocity action += kinetic_energy * dt return action def build_time_points(ta: float, tb: float, n_points: int) -> List[float]: if n_points < 2: raise ValueError("N must be at least 2.") dt = (tb - ta) / (n_points - 1) return [ta + step * dt for step in range(n_points)] def draw_circle(draw: ImageDraw.ImageDraw, center: tuple[float, float], size: int, color: tuple[int, int, int]) -> None: px, py = center draw.ellipse([(px - size, py - size), (px + size, py + size)], fill=color, outline=color) def draw_square(draw: ImageDraw.ImageDraw, center: tuple[float, float], size: int, color: tuple[int, int, int]) -> None: px, py = center draw.rectangle([(px - size, py - size), (px + size, py + size)], fill=color, outline=color) def draw_triangle(draw: ImageDraw.ImageDraw, center: tuple[float, float], size: int, color: tuple[int, int, int]) -> None: px, py = center points = [(px, py - size), (px - size, py + size), (px + size, py + size)] draw.polygon(points, fill=color, outline=color) def draw_diamond(draw: ImageDraw.ImageDraw, center: tuple[float, float], size: int, color: tuple[int, int, int]) -> None: px, py = center points = [(px, py - size), (px - size, py), (px, py + size), (px + size, py)] draw.polygon(points, fill=color, outline=color) def draw_cross(draw: ImageDraw.ImageDraw, center: tuple[float, float], size: int, color: tuple[int, int, int]) -> None: px, py = center draw.line([(px - size, py - size), (px + size, py + size)], fill=color, width=2) draw.line([(px - size, py + size), (px + size, py - size)], fill=color, width=2) def draw_plus(draw: ImageDraw.ImageDraw, center: tuple[float, float], size: int, color: tuple[int, int, int]) -> None: px, py = center draw.line([(px - size, py), (px + size, py)], fill=color, width=2) draw.line([(px, py - size), (px, py + size)], fill=color, width=2) def draw_marker( draw: ImageDraw.ImageDraw, center: tuple[float, float], marker_name: str, size: int, color: tuple[int, int, int], ) -> None: if marker_name == "circle": draw_circle(draw, center, size, color) elif marker_name == "square": draw_square(draw, center, size, color) elif marker_name == "triangle": draw_triangle(draw, center, size, color) elif marker_name == "diamond": draw_diamond(draw, center, size, color) elif marker_name == "cross": draw_cross(draw, center, size, color) else: draw_plus(draw, center, size, color) def draw_trajectory_image( image_path: Path, time_points: Sequence[float], straight_path: Sequence[float], random_cases: Sequence[Dict[str, object]], ) -> None: width = 1800 height = 1200 margin_left = 150 margin_right = 460 margin_top = 110 margin_bottom = 150 image = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(image) try: title_font = ImageFont.truetype("DejaVuSans.ttf", 50) label_font = ImageFont.truetype("DejaVuSans.ttf", 34) tick_font = ImageFont.truetype("DejaVuSans.ttf", 28) legend_title_font = ImageFont.truetype("DejaVuSans.ttf", 34) legend_font = ImageFont.truetype("DejaVuSans.ttf", 28) except OSError: title_font = ImageFont.load_default() label_font = ImageFont.load_default() tick_font = ImageFont.load_default() legend_title_font = ImageFont.load_default() legend_font = ImageFont.load_default() all_positions = list(straight_path) for case in random_cases: all_positions.extend(case["path"]) t_min = min(time_points) t_max = max(time_points) x_min = min(all_positions) x_max = max(all_positions) if t_min == t_max: t_max = t_min + 1.0 if x_min == x_max: x_max = x_min + 1.0 x_padding = max((x_max - x_min) * 0.08, 1.0) x_min -= x_padding x_max += x_padding plot_left = margin_left plot_right = width - margin_right plot_top = margin_top plot_bottom = height - margin_bottom def map_point(time_value: float, position_value: float) -> tuple[float, float]: x_ratio = (time_value - t_min) / (t_max - t_min) y_ratio = (position_value - x_min) / (x_max - x_min) px = plot_left + x_ratio * (plot_right - plot_left) py = plot_bottom - y_ratio * (plot_bottom - plot_top) return px, py draw.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline=(0, 0, 0), width=2) tick_count = 5 for tick in range(tick_count + 1): x_value = t_min + (t_max - t_min) * tick / tick_count y_value = x_min + (x_max - x_min) * tick / tick_count tick_x = plot_left + (plot_right - plot_left) * tick / tick_count tick_y = plot_bottom - (plot_bottom - plot_top) * tick / tick_count draw.line([(tick_x, plot_top), (tick_x, plot_bottom)], fill=(225, 225, 225), width=1) draw.line([(plot_left, tick_y), (plot_right, tick_y)], fill=(225, 225, 225), width=1) draw.line([(tick_x, plot_bottom), (tick_x, plot_bottom + 12)], fill=(0, 0, 0), width=3) draw.line([(plot_left - 12, tick_y), (plot_left, tick_y)], fill=(0, 0, 0), width=3) draw.text((tick_x - 28, plot_bottom + 24), f"{x_value:.1f}", fill=(0, 0, 0), font=tick_font) draw.text((28, tick_y - 14), f"{y_value:.1f}", fill=(0, 0, 0), font=tick_font) straight_points = [map_point(t, x) for t, x in zip(time_points, straight_path)] draw.line(straight_points, fill=(35, 95, 210), width=6) for point in (straight_points[0], straight_points[-1]): draw_circle(draw, point, 8, (35, 95, 210)) marker_step = max(1, len(time_points) // 12) for case in random_cases: points = [map_point(t, x) for t, x in zip(time_points, case["path"])] draw.line(points, fill=case["color"], width=4) for point in points[::marker_step]: draw_marker(draw, point, case["marker"], 6, case["color"]) draw.text((margin_left, 28), "Least Action Trajectory Comparison", fill=(0, 0, 0), font=title_font) draw.text((width // 2 - 70, height - 72), "time t", fill=(0, 0, 0), font=label_font) draw.text((24, 28), "position x", fill=(0, 0, 0), font=label_font) legend_left = plot_right + 24 legend_top = margin_top legend_bottom = plot_bottom draw.rectangle( [legend_left - 10, legend_top, width - 30, legend_bottom], fill=(250, 248, 242), outline=(210, 205, 195), width=2, ) draw.text((legend_left, legend_top + 22), "Legend", fill=(0, 0, 0), font=legend_title_font) item_y = legend_top + 102 draw.line([(legend_left, item_y + 14), (legend_left + 66, item_y + 14)], fill=(35, 95, 210), width=6) draw.text((legend_left + 86, item_y - 4), "straight path", fill=(0, 0, 0), font=legend_font) item_y += 64 for case in random_cases: draw.line([(legend_left, item_y + 14), (legend_left + 66, item_y + 14)], fill=case["color"], width=4) draw_marker(draw, (legend_left + 33, item_y + 14), case["marker"], 6, case["color"]) label = f"xmax = {case['xmax']:.6g}" draw.text((legend_left + 86, item_y - 4), label, fill=(0, 0, 0), font=legend_font) item_y += 60 image.save(image_path, format="JPEG", quality=95) def render_stats_cards(case_count: int, straight_action: float, min_xmax: float, best_ratio: float) -> str: return "\n".join( [ '
Least Action / Case 01
页面使用同一组基础扰动,分别按不同的 xmax 缩放随机路径,并比较它们的离散作用量如何逐步收敛到匀速直线轨迹的作用量。
参数:m={mass:.6g},
N={n_points},
seed={seed},
xmax={", ".join(f"{case['xmax']:.6g}" for case in random_cases)}
{observation}
| xmax | random action | straight action | random / straight | difference |
|---|
先按起点和终点做等时间间隔的线性插值,再基于离散速度计算作用量。
以直线路径为基线,在每个中间采样点加入均匀分布扰动,并保持起点终点不变。
页面中的公式由 MathJax 在浏览器中渲染。若本地打开后暂时没有看到公式样式,请确认浏览器能够访问
cdn.jsdelivr.net。