코드로 실험하는 일상 과학 · 시즌 1, 2편
사고도 공사도 없는데 길이 막힐 수 있다. 차 한 대에서 시작한 작은 속도 차이가 뒤차로 전달되는 동안 커지면 된다. 차들은 앞으로 가지만, 차들이 느려지는 구간은 뒤쪽으로 이동한다. 흔히 유령정체라고 부르는 현상이다.
앞이 뚫린 뒤에도 “도대체 어디가 막혔던 걸까”라는 의문이 남는 이유가 여기에 있다. 정체의 원인이 도로 위 한 장소에 고정돼 있지 않을 수 있다. 다만 실제 도로의 모든 정체가 같은 과정으로 생기는 것은 아니다.
이번에는 신호등도 합류 지점도 없는 원형도로를 파이썬으로 만든다. 같은 도로에 같은 차량을 놓고, 앞차에 맞춰 속도를 조정하는 시간척도만 바꿔 본다. 코드를 읽지 않아도 아래 그림과 결과를 따라갈 수 있다. 직접 확인하려면 뒤의 실행 방법과 전체 코드로 이동하면 된다.

장애물 없는 도로에서도 정체가 생겼다
이 질문을 실제 차량으로 시험한 연구가 있다. Sugiyama 등의 2008년 실험에서는 둘레 230 m의 균일한 원형도로에 차량 22대를 배치했다. 초기에는 간격을 거의 고르게 맞추고 약 30 km/h로 달렸다. 운전자들은 앞차를 안전하게 따라가면서 순항속도를 유지하려 했다.
시간이 흐르자 작은 간격 변동이 커졌고, 몇 대가 잠시 완전히 멈추는 정체 집단이 나타났다. 그 집단은 차량 진행 방향의 반대로 움직였다. 병목이 없어도 정체가 생길 수 있다는 실험적 근거다. 실험 원문, 논문 4–6쪽(PDF 6–8쪽)과 그림 2–4에서 조건과 궤적을 확인할 수 있다.
이 글의 계산은 그 실험을 그대로 재현한 것이 아니다. 아래의 도로 길이, 목표속도 함수, 조정 시간은 현상을 설명하려고 정한 모형 조건이다. 이 글의 그림은 실측 자료가 아니라 이 코드가 계산한 궤적이다.
앞차와의 간격을 보고 속도를 조금씩 바꾼다
차량마다 규칙은 하나다. 앞차와 가까우면 낮은 속도를, 멀면 높은 속도를 목표로 삼고 그쪽으로 속도를 조정한다. 목표속도가 바뀌었다고 실제 속도가 즉시 바뀌지는 않는다. 이 조정의 빠르기를 τ(타우)라는 시간으로 나타낸다.
여기서 τ는 “그동안 아무 반응도 하지 않는 시간”이 아니다. 현재 간격을 계속 읽으며 속도를 서서히 바꾸는 시간척도다. τ가 작으면 목표속도에 빨리 접근하고, 크면 천천히 접근한다. 명시적인 인지·반응 지연을 넣지 않는 Bando 등의 최적속도 모형을 바탕으로 했다.
설명용 도로의 둘레는 400 m, 차량 수는 22대, 차량 길이는 5 m다. 차량의 기준점 간격은 18.18 m이고, 차체 사이의 빈 간격은 13.18 m다. 이 간격에서 모형의 균일 주행속도는 약 15.74 km/h다. 두 간격을 혼동하면 목표속도 계산부터 달라진다.
처음에 한 대만 0.3 m/s, 시속으로 1.08 km/h 느리게 만든다. 이후에는 임의의 제동이나 난수를 추가하지 않는다. 같은 시작 상태에서 τ=1.0초와 τ=0.3초를 각각 600초 동안 계산했다.
작은 차이가 줄어드는 경우, 자라는 경우
τ=1.0초에서는 속도 차이가 커졌다. 전체 계산에서 가장 느린 차량의 속도는 약 1.91 km/h까지 내려갔다. 완전히 멈추지는 않았지만, 느린 무리에 들어갔다가 빠져나오는 흐름이 반복됐다. τ=0.3초에서는 같은 작은 감속이 퍼지면서 잦아들었고, 차량들은 거의 균일한 속도로 돌아갔다.

오른쪽 그림에서 차량의 선은 시간이 지날수록 앞으로 나아간다. 그런데 어두운 구간을 이어 보면 뒤쪽으로 기울어진 띠가 보인다. 차량이 후진하는 것이 아니다. 뒤에서 도착한 차량이 새로 느려지고, 앞쪽 차량은 가속해서 빠져나가므로 “느려지는 자리”가 뒤로 옮겨간다.
차량 간 속도 차이를 숫자로도 확인했다. 한 시점에 22대 속도의 표준편차를 구하면, 모두 같은 속도일 때는 0에 가깝고 빠른 차와 느린 차가 섞일수록 커진다. 이것은 교통량이나 평균 지체시간을 대신하는 지표가 아니라, 이 글에서 요동의 크기를 비교하려고 고른 지표다.
처음의 표준편차는 두 조건 모두 약 0.225 km/h였다. 600초 뒤 τ=1.0초 조건은 약 15.47 km/h, τ=0.3초 조건은 약 0.00000018 km/h였다. 같은 도로와 같은 작은 시작 차이도 속도 조정 규칙에 따라 다른 결과로 이어졌다.

대조조건이 알려 주는 것
시작 감속을 전혀 주지 않은 τ=1.0초 조건도 계산했다. 수학적으로 완벽히 균일한 상태는 그대로 달릴 수 있다. 실제 부동소수점 계산에서는 아주 작은 반올림 오차가 증폭돼, 600초 뒤 속도 표준편차가 약 0.00068 km/h가 됐다. 그림에서 거의 0으로 보인다고 이 조건을 안정하다고 판정하면 안 된다. 이번 600초 동안 큰 정체파까지 자라지 않았다는 뜻이다. 시작 감속을 주지 않은 조건의 작은 수치는 실행 환경과 반올림 오차에 따라 달라질 수 있다.
τ를 0.3·0.5·0.6·0.7·0.8·1.0·1.2초로 바꿔 같은 실험도 반복했다. 경계를 조금 넘은 0.7초에서는 600초가 지나도 요동이 아직 작았다. 불안정하다는 수학적 판정과 짧은 관측 시간 안에 눈에 띄는 정체가 나타난다는 말은 다르다.
시작 감속을 0.03·0.3·0.6 m/s로 바꾼 세 실험에서는 마지막 100초의 속도 표준편차가 모두 약 15.51 km/h였다. 반면 차량 수를 14·22·28·32대로 바꾸면 결과가 단순히 차량 수에 비례하지 않았다. 이 목표속도 함수에서는 22대 조건이 작은 교란에 불안정했고, 나머지 세 조건의 교란은 줄었다. 특히 32대 조건의 균일 주행속도는 약 4.00 km/h에 불과하다. 요동이 작다는 것이 도로가 빠르거나 원활하다는 뜻은 아니다.
이 계산으로 말할 수 있는 범위
이번 결과는 병목이 없어도 차량 사이의 상호작용만으로 저속 구간이 만들어질 수 있음을 보여 주는 수치 예다. 특정 도로의 정체 시작 시각이나 운전자의 실제 반응시간을 예측하지 않는다. 새롭게 발견한 법칙이나 현장 측정값도 아니다.
모든 차량은 같은 규칙을 따르고 차선을 바꾸지 않는다. 경사, 합류, 운전자 차이, 최대 가속·제동 한계도 넣지 않았다. 이 모형에는 충돌을 언제나 막아 주는 안전 제어기가 없다. 계산 중 차체 간격이 0이 되면 중단하도록 했으며, 보고한 주 실험에서는 최소 간격이 약 5.16 m였다. 이것을 실제 도로의 안전거리로 사용할 수는 없다.
도로가 다시 열렸을 때 사라지는 것은 늘 사고 흔적이나 공사 구간만이 아니다. 뒤차로 옮겨 가던 저속 구간을 통과했을 수도 있다. 이번 그림에서 확인한 것은 바로 그 이동이다. 차 한 대의 움직임과 차량들이 함께 만드는 흐름을 따로 보면, 앞에 아무것도 없는 정체가 조금 덜 이상해진다.
직접 실행하기
아래 전체 코드를 펼쳐 내용 전체를 traffic_experiment.py라는 UTF-8 파일로 저장한다. 파일 확장자가 .py.txt가 되지 않게 확인한다. 이 파일 하나에 계산, 검증, 그림, 영상 생성 코드가 모두 들어 있으며, 별도로 받아야 할 자체 모듈은 없다.
필요한 환경은 Python 3.10 이상과 NumPy, SciPy, Matplotlib이다. 이번 실행은 Python 3.12.14, NumPy 2.3.5, SciPy 1.17.0, Matplotlib 3.10.8에서 확인했다. 영상 생성에는 기존에 설치된 ffmpeg도 필요하다. 프로그램은 소프트웨어를 자동 설치하지 않는다.
python traffic_experiment.py --output results
명령은 파일을 저장한 폴더에서 실행한다. results 폴더에 그림 3장, results.json, timeseries.csv, trajectories.npz가 생긴다. 기존 결과를 덮어쓰지 않으므로 results가 이미 있으면 --output results2처럼 새 폴더 이름을 지정한다. CSV에는 세 주 조건의 시각·차량별 위치·속도·간격이 들어 있다.
20초 무음 영상까지 만들려면 다음 명령을 쓴다. ffmpeg가 없으면 영상만 생략되고 이유가 results.json에 기록된다. ffmpeg 자체가 있어도 인코더가 동작하지 않으면 오류를 확인해야 한다.
python traffic_experiment.py --output results_with_video --video
results.json의 tau_sweep, density_sensitivity, initial_offset_sensitivity에는 조건 변화 결과가 들어 있다. 모든 길이는 m, 시간은 초, 속도는 m/s로 저장한다. 그림에 표시한 km/h는 m/s에 3.6을 곱한 값이다. 난수는 사용하지 않는다.
계산식과 수치 검증
위치는 원을 여러 바퀴 돌아도 계속 증가하는 좌표로 계산한다. 마지막 차량의 앞차 좌표에만 도로 둘레 400 m를 더한다. 그림을 그릴 때만 위치를 0–400 m로 접는다. 따라서 원의 경계에서 차량 순서가 바뀌거나 가짜 간격이 생기지 않는다.
s_i = x_(i+1) - x_i - 5
dx_i/dt = v_i
dv_i/dt = (V(s_i) - v_i) / tau
V(s) = 12 * [tanh((s - 15)/7) + tanh(15/7)] / [1 + tanh(15/7)]
s는 차체 사이 빈 간격(m), V(s)는 목표속도(m/s)다. 15와 7에는 각각 m 단위가 있어 tanh에 들어가는 값은 무차원이다. 도로 양끝을 잇는 마지막 쌍에는 위에서 설명한 400 m 보정을 적용한다. V(0)=0이지만, 그것만으로 어떤 초기조건에서도 충돌이 없다는 보장이 생기지는 않는다.
이 유한한 22대 원에서 균일 상태가 작은 교란에 안정하려면 τV′(s₀)<1/[1+cos(2π/22)]가 성립해야 한다. 오른쪽은 약 0.51034이며, s₀=13.1818 m에서 V′는 약 0.81286/초다. 따라서 경계 τ는 약 0.6278초다. 이 식은 작은 교란의 선형 판정이며, 유한한 감속 뒤의 정체 크기나 발생 시간을 직접 주지는 않는다.
시간 적분에는 SciPy의 DOP853을 사용했다. 상대 허용오차는 10⁻⁹, 절대 허용오차는 10⁻¹¹, 내부 최대 시간 간격은 0.5초다. 결과를 0.2초마다 저장했지만, 이 저장 간격을 적분 시간 간격으로 고정한 것은 아니다.
최대 시간 간격을 0.25초로 줄이고 허용오차를 더 엄격히 한 DOP853 결과와 비교했을 때, 저장된 모든 시각·차량에서 속도의 최대 차이는 약 1.93×10⁻⁷ m/s 이하였다. 다른 적분법 RK45와 비교한 최대 차이는 약 2.12×10⁻⁶ m/s였다. 이 비교는 같은 방정식의 수치해 확인이며, 현실의 타당성을 별도로 입증하지 않는다.
주 실험의 모든 저장 시각에서 차량 길이와 빈 간격의 합은 도로 둘레 400 m와 약 2.28×10⁻¹³ m 이내로 일치했다. 음수 속도나 간격을 몰래 0으로 바꾸는 보정은 하지 않았다. 코드의 접촉 중단 검사와 저장 시각의 양수 검사를 통과한 결과만 사용했다.
전체 실행 코드
traffic_experiment.py 전체 코드 펼치기 · 생략 없음
#!/usr/bin/env python3
"""Constructed ring-road OVM experiment; not measured traffic or a replication.
Python 3.10+, numpy, scipy, matplotlib. Optional MP4: an existing ffmpeg.
Run: python traffic_experiment.py --output results
Add --video to make the 20-second silent animation. No software is installed.
All authored calculation, plotting and video code is in this one file.
"""
from pathlib import Path
import argparse
import csv
import hashlib
import json
import platform
import shutil
import numpy as np
import scipy
from scipy.integrate import solve_ivp
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.collections import LineCollection
from matplotlib.animation import FFMpegWriter
N, LENGTH, CAR = 22, 400.0, 5.0
VMAX, CENTER, WIDTH = 12.0, 15.0, 7.0
DURATION, SAMPLE_DT = 600.0, 0.2
MAIN_TAU, CONTROL_TAU, PULSE = 1.0, 0.3, 0.3
def desired_speed(gap):
"""Bumper gap in m -> desired speed in m/s; V(0)=0."""
return VMAX * (np.tanh((gap - CENTER) / WIDTH)
+ np.tanh(CENTER / WIDTH)) / (1 + np.tanh(CENTER / WIDTH))
def speed_slope(gap):
return VMAX / WIDTH / (1 + np.tanh(CENTER / WIDTH)) / np.cosh((gap - CENTER) / WIDTH) ** 2
def gaps(x, n=N):
# Unwrapped coordinates retain vehicle order, including the periodic last pair.
return np.concatenate((np.diff(x, axis=-1),
(x[..., :1] + LENGTH - x[..., -1:])), axis=-1) - CAR
def simulate(tau=MAIN_TAU, pulse=PULSE, n=N, rtol=1e-9, atol=1e-11,
max_step=0.5, method='DOP853'):
eq_gap = LENGTH / n - CAR
eq_speed = float(desired_speed(eq_gap))
y0 = np.r_[np.arange(n) * LENGTH / n, np.full(n, eq_speed)]
y0[n] -= pulse # One initial speed offset, not continued random braking.
def rhs(t, y):
x, v = y[:n], y[n:]
return np.r_[v, (desired_speed(gaps(x, n)) - v) / tau]
def contact(t, y):
return float(gaps(y[:n], n).min())
contact.terminal = True
contact.direction = -1
times = np.linspace(0, DURATION, round(DURATION / SAMPLE_DT) + 1)
sol = solve_ivp(rhs, (0, DURATION), y0, t_eval=times, events=contact,
method=method, rtol=rtol, atol=atol, max_step=max_step)
if not sol.success or sol.status != 0 or len(sol.t) != len(times):
raise RuntimeError('Integration failed or a zero-gap contact occurred.')
x, v = sol.y[:n].T, sol.y[n:].T
s = gaps(x, n)
if not np.isfinite(sol.y).all() or s.min() <= 0 or v.min() < -1e-9:
raise RuntimeError('Invalid gap, reverse speed, or nonfinite result.')
return dict(t=times, x=x, v=v, gap=s, n=n, tau=tau,
pulse=pulse, eq_gap=eq_gap, eq_speed=eq_speed)
def summarize(run):
t, v, s = run['t'], run['v'], run['gap']
std = v.std(axis=1, ddof=0)
# Finite-N linear threshold: tau*V'(gap)<1/(1+cos(2*pi/N)).
q = float(run['tau'] * speed_slope(run['eq_gap']))
threshold = float(1 / (1 + np.cos(2 * np.pi / run['n'])))
return dict(n=run['n'], tau_s=run['tau'], initial_offset_m_s=run['pulse'],
equilibrium_gap_m=run['eq_gap'], equilibrium_speed_m_s=run['eq_speed'],
speed_std_initial_m_s=float(std[0]), speed_std_600s_m_s=float(std[-1]),
final_100s_mean_speed_std_m_s=float(std[t >= 500].mean()),
min_gap_m=float(s.min()), min_speed_m_s=float(v.min()),
max_speed_m_s=float(v.max()),
ring_length_error_m=float(np.abs((s + CAR).sum(axis=1) - LENGTH).max()),
tau_times_Vprime=q, finite_N_linear_threshold=threshold,
linear_uniform_state='stable' if q < threshold else 'unstable')
def setup_plots():
plt.rcParams.update({'font.family': 'DejaVu Sans', 'font.size': 11,
'axes.spines.top': False, 'axes.spines.right': False,
'figure.facecolor': '#f7f5ef', 'axes.facecolor': '#f7f5ef',
'savefig.facecolor': '#f7f5ef'})
def ring_axis(ax, run, index):
theta = 2 * np.pi * (run['x'][index] % LENGTH) / LENGTH
angle = np.linspace(0, 2*np.pi, 400)
ax.plot(np.cos(angle), np.sin(angle), color='#dedbd1', linewidth=20, zorder=0)
points = ax.scatter(np.cos(theta), np.sin(theta), c=run['v'][index] * 3.6,
vmin=0, vmax=VMAX*3.6, cmap='plasma', s=65,
edgecolor='#162f37', linewidth=0.6)
ax.scatter(np.cos(theta[0]), np.sin(theta[0]), s=155, facecolors='none',
edgecolors='#162f37', linewidth=1.8)
ax.text(0, 0.10, f"{run['t'][index]:.0f} s", ha='center', fontsize=23)
ax.text(0, -0.20, '22 cars | 400 m\nCounterclockwise travel', ha='center', fontsize=10)
ax.annotate('', xy=(0.75, 0.83), xytext=(0.96, 0.60),
arrowprops={'arrowstyle': '->', 'color': '#162f37', 'lw': 2})
ax.set(xlim=(-1.25,1.25), ylim=(-1.25,1.25), aspect='equal')
ax.axis('off')
return points
def figures(out, main, stable, perfect, sweeps):
setup_plots()
fig, axes = plt.subplots(1, 2, figsize=(11,5), layout='constrained')
for ax, seconds in zip(axes, [0, 500]):
sc = ring_axis(ax, main, round(seconds/SAMPLE_DT))
fig.suptitle('A small slowdown becomes a moving queue', fontsize=20, fontweight='bold')
fig.colorbar(sc, ax=axes, shrink=.75, label='Vehicle speed (km/h)')
fig.savefig(out/'01_ring_comparison.png', dpi=150)
plt.close(fig)
fig, axes = plt.subplots(1,2,figsize=(11,6),sharey=True,layout='constrained')
for ax, run, label in zip(axes,[stable,main],['Fast adjustment: tau = 0.3 s', 'Slow adjustment: tau = 1.0 s']):
# Draw trajectory segments, masking wrap crossings at 0/400 m.
pos=run['x'] % LENGTH
for k in range(N):
xy=np.c_[pos[:,k],run['t']]
segments=np.stack((xy[:-1],xy[1:]),axis=1)
valid=np.abs(np.diff(pos[:,k])) < LENGTH/2
lc=LineCollection(segments[valid],cmap='plasma',norm=plt.Normalize(0,VMAX*3.6),linewidths=.65)
lc.set_array(run['v'][:-1,k][valid]*3.6)
ax.add_collection(lc)
ax.set(xlim=(0,LENGTH),ylim=(0,DURATION),xlabel='Position along road (m)',title=label)
axes[0].set_ylabel('Time (s)')
fig.suptitle('Cars travel forward; slow patches travel backward',fontsize=18,fontweight='bold')
fig.colorbar(lc,ax=axes,label='Vehicle speed (km/h)',shrink=.8)
fig.savefig(out/'02_spacetime.png',dpi=170)
plt.close(fig)
fig,axes=plt.subplots(1,2,figsize=(11,4.8),layout='constrained')
for run,label,color in [(main,'tau=1.0 s, offset=0.3 m/s','#cb5b37'),
(stable,'tau=0.3 s, offset=0.3 m/s','#217e8a'),
(perfect,'tau=1.0 s, no offset','#555555')]:
axes[0].plot(run['t'],run['v'].std(axis=1)*3.6,label=label,color=color,lw=2)
axes[0].set(xlabel='Time (s)',ylabel='Across-car speed SD (km/h)',title='Same road, three controls')
axes[0].legend(fontsize=8)
taus=[r['tau_s'] for r in sweeps]
fluct=[r['final_100s_mean_speed_std_m_s']*3.6 for r in sweeps]
axes[1].plot(taus,fluct,'o-',color='#cb5b37',lw=2)
critical=1/(1+np.cos(2*np.pi/N))/speed_slope(LENGTH/N-CAR)
axes[1].axvline(critical,color='#666666',ls='--',label=f'Linear threshold: {critical:.3f} s')
axes[1].set(xlabel='Speed-adjustment time tau (s)',ylabel='Mean speed SD, 500–600 s (km/h)',title='Changing one model parameter')
axes[1].legend(fontsize=9)
fig.savefig(out/'03_controls.png',dpi=160)
plt.close(fig)
def video(out, main):
if shutil.which('ffmpeg') is None:
return {'status':'unavailable','reason':'No existing ffmpeg; no install attempted.'}
setup_plots()
fig,ax=plt.subplots(figsize=(6.4,6.4),layout='constrained')
writer=FFMpegWriter(fps=24,codec='libx264',bitrate=1600,
extra_args=['-pix_fmt','yuv420p','-movflags','+faststart'])
path=out/'04_ring_motion.mp4'
with writer.saving(fig,str(path),dpi=100):
for seconds in np.linspace(0,DURATION,480):
ax.clear()
index=int(round(seconds/SAMPLE_DT))
ring_axis(ax,main,index)
ax.set_title('PHANTOM JAM / constructed simulation\nDark = slow | Bright = fast | 30x time',fontsize=12)
writer.grab_frame()
plt.close(fig)
return {'status':'created','filename':path.name,'seconds':20,'fps':24,
'audio':'none','time_compression':30}
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output',type=Path,default=Path('results'))
parser.add_argument('--video',action='store_true')
args=parser.parse_args()
args.output.mkdir(parents=True,exist_ok=False)
out=args.output
main_run=simulate()
stable=simulate(tau=CONTROL_TAU)
perfect=simulate(pulse=0)
runs={MAIN_TAU:main_run,CONTROL_TAU:stable}
for tau in [.5,.6,.7,.8,1.2]:
runs[tau]=simulate(tau=tau)
sweeps=[summarize(runs[tau]) for tau in sorted(runs)]
fine=simulate(rtol=1e-11,atol=1e-13,max_step=.25)
alternate=simulate(rtol=1e-10,atol=1e-12,max_step=.25,method='RK45')
checks={}
for name,other in [('tighter_DOP853',fine),('independent_RK45_method',alternate)]:
checks[name]={'max_position_difference_m':float(np.abs(main_run['x']-other['x']).max()),
'max_speed_difference_m_s':float(np.abs(main_run['v']-other['v']).max())}
if checks[name]['max_speed_difference_m_s'] > 1e-4:
raise RuntimeError('Solver cross-check exceeded 1e-4 m/s.')
# Density variation is a model sensitivity test, not a universal threshold claim.
density=[summarize(simulate(n=n)) for n in [14,22,28,32]]
perturb=[summarize(simulate(pulse=p)) for p in [.03,.3,.6]]
setup_plots()
figures(out,main_run,stable,perfect,sweeps)
video_result=video(out,main_run) if args.video else {'status':'not_requested'}
with (out/'timeseries.csv').open('w',newline='',encoding='utf-8') as f:
w=csv.writer(f)
w.writerow(['condition','time_s','car_index','unwrapped_position_m','speed_m_s','bumper_gap_m'])
for name,run in [('slow_adjustment',main_run),('fast_adjustment',stable),('no_offset',perfect)]:
for j,t in enumerate(run['t']):
for k in range(N):
w.writerow([name,t,k,run['x'][j,k],run['v'][j,k],run['gap'][j,k]])
np.savez_compressed(out/'trajectories.npz',time_s=main_run['t'],x_m=main_run['x'],
v_m_s=main_run['v'],gap_m=main_run['gap'])
summary={'classification':'Constructed deterministic numerical demonstration, not measured data or a new discovery.',
'model':'dx_i/dt=v_i; dv_i/dt=(V(s_i)-v_i)/tau; s_i=x_(i+1)-x_i-CAR, periodic ring',
'units':{'position_gap_car_length':'m','time_tau':'s','speed':'m/s','acceleration':'m/s^2'},
'parameters':{'N':N,'road_m':LENGTH,'car_m':CAR,'Vmax_m_s':VMAX,'center_gap_m':CENTER,'width_m':WIDTH,
'duration_s':DURATION,'sample_dt_s':SAMPLE_DT,'main_tau_s':MAIN_TAU,'initial_offset_m_s':PULSE},
'main':summarize(main_run),'stable_control':summarize(stable),'no_offset_control':summarize(perfect),
'tau_sweep':sweeps,'density_sensitivity':density,'initial_offset_sensitivity':perturb,
'numerics':{'method':'DOP853','rtol':1e-9,'atol':1e-11,'max_step_s':.5,
'output_interval_s':SAMPLE_DT,'cross_checks':checks,
'negative_speed_clipping':False,'gap_clipping':False,
'contact_event':'stop on minimum bumper gap crossing zero',
'validation_scope':'continuous event detector plus sampled positivity; not a universal collision-free guarantee'},
'video':video_result,
'reproducibility':{'python':platform.python_version(),'numpy':np.__version__,'scipy':scipy.__version__,
'matplotlib':matplotlib.__version__,'randomness':'none',
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()},
'limitations':['Identical deterministic vehicles; one lane; no lane changes or driver heterogeneity.',
'tau is a speed-relaxation time, not an explicit perception/reaction delay.',
'No hard acceleration, braking, or collision-avoidance controller. Unsafe parameter sets must be rejected.',
'Parameters were selected for explanation and are not fitted to the 2008 road experiment.',
'Exactly uniform initial conditions are an ideal symmetry control; absence of a seed does not establish stability.',
'Do not turn model tau or density thresholds into road-driving recommendations.']}
(out/'results.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(summary,ensure_ascii=False,indent=2))
if __name__=='__main__':
main()
출처와 연재 안내
Sugiyama, Y. et al. (2008). Traffic jams without bottlenecks—experimental evidence for the physical mechanism of the formation of a jam. New Journal of Physics 10, 033001. DOI · 공개 원문. 실제 원형도로 실험의 조건과 정체파 관측을 대조했다.
Bando, M. et al. (1995). Dynamical model of traffic congestion and numerical simulation. Physical Review E 51, 1035–1042. DOI · 공개 원문. 식 (1)과 (11)을 바탕으로 속도 조정 모형과 유한 차량 수의 선형 안정성 조건을 확인했다. 이 글에서는 차량 길이와 별도의 목표속도 매개변수를 추가했다.
이전 편: 뜨거운 물이 더 빨리 얼까? 음펨바 효과와 파이썬 수치실험. 다음 편은 비를 피할 때 걷는 것과 뛰는 것의 차이를 다룬다. 시즌 1은 여섯 편으로 구성한다.