코드로 실험하는 일상 과학 · 시즌 1, 5편
전자레인지의 회전판은 음식의 각 부분이 서로 다른 위치를 지나게 한다. 그래서 한곳에 고정해 둘 때보다 공간적인 가열 편차를 줄이는 데 도움이 될 수 있다. 그러나 돌린다는 사실만으로 모든 부분이 같은 온도에 도달하지는 않는다. 회전 중심은 움직이지 않고, 다른 반지름의 점들은 서로 다른 원을 돌기 때문이다.
이번에는 온도를 예측하지 않는다. 고정된 정상파 무늬를 하나 정한 뒤, 가상의 점들이 그 안에 멈춰 있거나 원을 그리며 움직일 때 전기장 제곱을 얼마나 누적해서 만나는지 파이썬으로 계산한다. 실제 전자레인지나 음식으로 한 실험이 아니라, 회전의 역할과 한계를 분리해 보는 설명용 모형이다.

이 글에는 재생용 영상을 첨부하지 않았다. 아래 코드를 실행하면 계산 그림 3개가 생성되고, 영상 옵션을 선택하면 회전 경로를 보여 주는 8초 영상도 만들 수 있다. 이미지를 누르면 원본 크기로 볼 수 있다.
1. 밝은 곳과 어두운 곳을 지나면 무엇이 달라질까
마주 진행하는 파동이 겹치면 공간적으로 진폭이 큰 곳과 작은 곳이 생길 수 있다. 여기서는 그 구조를 단순화해 I(x,y)=cos²(2πx/0.12)라는 무늬를 미리 정했다. x와 y의 단위는 미터다. I는 빠른 진동을 평균한 전기장 제곱을 최대값으로 나눈 무차원 값이며, 전력 W나 온도 °C가 아니다. 진폭의 공간 파장은 12cm이고, 제곱한 무늬는 6cm마다 반복된다.
실제 금속 상자의 경계조건을 풀지 않았으며 음식이 전기장을 바꾸는 효과도 없다. 특정 제품의 주파수·내부 치수·음식 성질을 입력한 전자기장 해석으로 읽으면 안 된다. 원형 영역의 반지름 11cm와 계산 시간 10초 역시 비교를 위해 선택한 값이다.
2. 같은 10초, 고정과 한 바퀴 회전
첫 조건에서는 각 점을 제자리에 둔다. 두 번째에서는 모든 점이 중심을 기준으로 10초 동안 한 바퀴 돈다. 두 조건의 시간과 색 눈금은 같다. 누적량 A는 ∫I(x(t),y(t))dt로 계산하므로 단위는 초(s)다. 정규화된 세기가 1인 곳에 10초 머물면 A는 10s다. 실제 흡수 에너지로 바꾸려면 재료의 손실 특성과 전기장 크기 등 별도의 정보가 필요하다.

201×201 격자 중 원 안의 점들을 비교했을 때 누적량의 변동계수는 고정 조건 0.7341, 회전 조건 0.2442였다. 변동계수는 표준편차를 평균으로 나눈 값이다. 이 모형과 영역에서는 회전 뒤 상대적인 공간 편차가 작아졌다는 뜻이다. 음식의 가열 균일도가 이 비율만큼 좋아진다는 측정 결과는 아니다.
전체 지도의 편차가 줄어든다고 해서 모든 점의 누적량이 늘어나는 것은 아니다. 중심에서 7cm 떨어진 점을 (7cm, 0)에서 출발시키면, 고정했을 때는 정규화 세기 I=0.75를 유지해 10초 누적량이 7.50s가 된다. 한 바퀴를 돌면 강한 곳과 약한 곳을 차례로 지나며 6.43s가 된다. 아래 그림은 이 한 점의 경로와 시간에 따른 세기를 함께 보여 준다.

3. 회전 중심이 그대로인 이유
중심에 놓인 점은 회전해도 같은 좌표에 남는다. 이번 무늬에서는 중심의 I가 1이므로 고정·회전 모두 누적량이 10s다. 중심에서 벗어난 점은 원을 돌지만, 같은 반지름에 있는 점들끼리만 같은 경로를 공유한다. 한 바퀴를 모두 돌아도 서로 다른 반지름의 평균은 같아질 필요가 없다.

따라서 이 그림만 보고 음식을 반드시 가장자리에 놓으라는 규칙을 만들 수는 없다. 무늬의 위치와 모양을 바꾸면 유리한 경로도 달라진다. 실제 기기는 여러 공간 모드, 용기, 음식의 수분과 형태, 열전도까지 영향을 받는다.
4. 수치가 계산 간격에 따라 바뀌는지 확인하기
한 바퀴를 360개 중간 시점으로 나누어 합산하고 720개로 다시 계산했다. 두 누적 지도에서 최대 절대 차이는 약 3.91×10⁻¹⁴s였다. 공간 격자를 201×201에서 401×401로 늘렸을 때 회전 조건 변동계수는 0.244219에서 0.244228로 바뀌었다. 선택한 지표는 이 범위의 간격 변경에 민감하지 않았다. 이것은 이 수치 적분의 안정성 확인이지 실제 전자레인지 모형의 정확성 검증은 아니다.
코드에는 누적량 범위, 회전 0회와 고정의 일치, 동일 시간 동안 정수 바퀴를 돌 때의 평균 일치, 역방향 회전, 중심점, 시간·공간 분해능을 확인하는 7개 검사가 있다. 한 바퀴를 채우지 못하거나 장 무늬 자체가 시간에 따라 변하면 정수 회전 비교의 결론을 그대로 쓸 수 없다.
5. 생활에서 가져갈 판단
회전은 위치를 바꾸는 방법이지 음식 전체를 섞는 방법은 아니다. 실제 조리는 제품과 식품의 지침을 따라야 한다. FDA는 전자레인지 사용 설명서에 맞는 사용과 용기를 권고하며, 음식 두께에 따른 열 전달도 설명한다. 이 코드는 조리 시간, 살균 여부, 먹어도 되는 온도를 판정하지 않는다. 빈 기기를 돌리거나 회전판·안전장치를 제거하는 실제 실험은 이 글의 실습에 포함하지 않는다.
앞선 한 지점의 파형 상쇄 실험에서는 위상과 진폭을 맞추는 문제를 보았다. 이번에는 공간에서 위치를 바꾸는 문제를 다뤘다. 같은 파동이라도 무엇을 고정하고 무엇을 움직이는지에 따라 질문이 달라진다.
코드 실습: 계산값과 그림 재현
2026년 11월 7일에 그림과 코드를 다시 실행해 확인했다. 실행 환경은 Python 3.12.14, NumPy 2.3.5, Matplotlib 3.10.8이다. 아래 전체 코드를 simulation.py로 저장한 뒤 python simulation.py를 실행한다. 외부 데이터 파일이나 별도 의존 코드 파일은 없다. NumPy·Matplotlib이 이미 있는 환경을 사용하며 새 패키지는 설치하지 않았다. 기본 실행에는 영상 도구가 필요하지 않다.
스크립트와 같은 위치의 output 폴더에 수치 기록 metrics.json, 비교 지도·회전 경로·반지름 곡선의 PNG와 SVG가 각각 3개씩 생성된다. 같은 이름의 결과가 있으면 덮어쓰므로 별도 폴더에서 실행한다. 설치된 Noto Sans CJK 한글 글꼴이 없으면 그림의 문구가 영어로 바뀐다. 대표 개념 이미지는 이 코드의 출력물이 아니다.
영상도 만들려면 FFmpeg와 H.264 인코더가 이미 준비된 환경에서 python simulation.py --video를 실행한다. 10초의 계산 경로를 8초로 압축한 rotation.mp4가 추가된다. 배경은 고정된 정규화 세기 I이며, 점 옆 숫자도 현재 위치의 I다. 이 옵션은 다시 실행해 8초 파일 생성을 확인했다.
전체 코드 열기 위치로 이동. 회전 효과를 더 확인하려면 WAVELENGTH와 turns를 하나씩 바꾸고 두 조건의 시간은 같게 유지한다. 검사의 기준은 기본 설정용이므로 변경한 설정에서 실패하면 원인부터 해석한다.
근거와 모형의 경계
FDA, Microwave Ovens: 전자레인지 가열의 일반 설명과 안전한 사용 원칙을 확인했다. 이 글의 수치 결과를 제공한 자료는 아니다.
Pitchai 외, Assessment of heating rate and non-uniform heating in domestic microwave ovens (2012), DOI 10.1080/08327823.2012.11689839: 원 연구의 공개 초록에서 정상파와 식품 유전 특성에 따른 비균일 가열, 위치별 측정 및 반복 시 가열 무늬의 차이를 확인했다. 논문 전체를 재현한 것이 아니며 여기의 단순 모형은 해당 연구의 측정값을 사용하지 않았다.
전체 파이썬 코드
"""Prescribed scalar standing-wave illustration, not an oven or heat solver.
Run: python simulation.py
Optional animation: python simulation.py --video (requires existing FFmpeg).
"""
from pathlib import Path
import argparse
import json
import os
import platform
import tempfile
os.environ.setdefault('MPLCONFIGDIR', tempfile.mkdtemp(prefix='microwave-mpl-'))
os.environ.setdefault('XDG_CACHE_HOME', tempfile.mkdtemp(prefix='microwave-cache-'))
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import font_manager
from matplotlib.patches import Circle
from matplotlib.animation import FuncAnimation, FFMpegWriter
OUT = Path(__file__).resolve().parent / 'output'
OUT.mkdir(exist_ok=True)
R, WAVELENGTH, DURATION = .11, .12, 10.0
BG, INK, MUTED, BLUE, ORANGE = '#f6f5ef', '#172c39', '#52636a', '#176a89', '#b76036'
FONT = next((f.name for f in font_manager.fontManager.ttflist
if 'Noto Sans CJK' in f.name), None)
KO = FONT is not None
def label(korean, english):
return korean if KO else english
if FONT:
plt.rcParams['font.family'] = FONT
plt.rcParams.update({'font.size': 18, 'axes.unicode_minus': False,
'figure.facecolor': BG, 'axes.facecolor': BG,
'savefig.facecolor': BG, 'text.color': INK,
'axes.labelcolor': MUTED, 'xtick.color': MUTED,
'ytick.color': MUTED, 'axes.edgecolor': '#aeb9b8',
'axes.spines.top': False, 'axes.spines.right': False,
'svg.fonttype': 'path'})
def intensity(x, y):
# Normalized cycle-averaged E squared, not watts or temperature.
return np.cos(2 * np.pi * x / WAVELENGTH) ** 2
def exposure(x, y, steps=360, turns=1.0):
total = np.zeros_like(x, dtype=float)
for a in (np.arange(steps) + .5) * 2 * np.pi * turns / steps:
total += intensity(x * np.cos(a) - y * np.sin(a),
x * np.sin(a) + y * np.cos(a))
return DURATION * total / steps
def grid(n):
q = np.linspace(-R, R, n)
x, y = np.meshgrid(q, q)
return x, y, x * x + y * y <= R * R
def cv(values):
return float(values.std() / values.mean())
def calculate():
x, y, mask = grid(201)
fixed = DURATION * intensity(x, y)
rotated = exposure(x, y)
x2, y2, mask2 = grid(401)
metrics = {'python': platform.python_version(), 'numpy': np.__version__,
'matplotlib': matplotlib.__version__,
'fixed_cv': cv(fixed[mask]), 'rotated_cv': cv(rotated[mask]),
'time_refinement_max_abs': float(np.max(np.abs(rotated - exposure(x, y, 720)))),
'center_fixed': float(fixed[100, 100]),
'center_rotated': float(rotated[100, 100]),
'spatial_cv_401': cv(exposure(x2, y2)[mask2])}
assert np.all(rotated >= 0) and np.all(rotated <= DURATION + 1e-12)
assert np.allclose(exposure(x, y, turns=0), fixed, rtol=0, atol=1e-12)
assert np.allclose(rotated, exposure(x, y, turns=2), rtol=0, atol=1e-12)
assert np.allclose(rotated, exposure(x, y, turns=-1), rtol=0, atol=1e-12)
assert metrics['center_rotated'] == DURATION
assert metrics['time_refinement_max_abs'] < 1e-10
assert abs(metrics['spatial_cv_401'] - metrics['rotated_cv']) < .005
metrics['checks'] = '7 assertions passed'
metrics['path_radius_cm'] = 7.0
metrics['path_fixed_exposure'] = float(DURATION * intensity(.07, 0))
metrics['path_rotated_exposure'] = float(exposure(np.array(.07), np.array(0.0)))
(OUT / 'metrics.json').write_text(json.dumps(metrics, indent=2), encoding='utf-8')
return x, y, fixed, rotated, metrics
def header(fig, number, title, subtitle):
fig.text(.065, .945, number, color=BLUE, fontsize=16, weight='bold')
fig.text(.065, .885, title, fontsize=30, weight='bold')
fig.text(.065, .835, subtitle, fontsize=16, color=MUTED)
def save(fig, stem):
fig.savefig(OUT / (stem + '.png'), dpi=180)
fig.savefig(OUT / (stem + '.svg'))
plt.close(fig)
def field_plot(ax, data, vmax=10):
# Preserve the 201 x 201 data. Clip the rectangular image at the exact disk.
# No smoothing is used to hide or alter the sampled values.
delta = 22 / 200
im = ax.imshow(data, origin='lower', extent=[-11-delta/2, 11+delta/2]*2,
vmin=0, vmax=vmax, cmap='cividis', interpolation='nearest')
circle = Circle((0, 0), 11, transform=ax.transData)
im.set_clip_path(circle)
ax.add_patch(Circle((0, 0), 11, fill=False, color=INK, lw=1.2))
ax.set(xlim=(-11.5, 11.5), ylim=(-11.5, 11.5), aspect='equal')
ax.set_xticks([-10, 0, 10])
ax.set_yticks([-10, 0, 10])
ax.tick_params(length=0, labelsize=15)
for spine in ax.spines.values():
spine.set_visible(False)
return im
def make_figures(x, y, fixed, rotated, metrics):
fig = plt.figure(figsize=(11, 8.2))
header(fig, '01 / COMPARISON',
label('같은 10초, 달라진 누적 무늬', 'Same ten seconds, different exposure patterns'),
label('두 조건은 같은 영역과 0–10 s 색 눈금을 사용합니다.',
'Same domain and shared 0–10 s color scale.'))
for pos, data, title, value in [([.065, .29, .39, .48], fixed,
label('제자리에 고정', 'Fixed in place'), metrics['fixed_cv']),
([.545, .29, .39, .48], rotated,
label('한 바퀴 회전', 'One full turn'), metrics['rotated_cv'])]:
ax = fig.add_axes(pos)
im = field_plot(ax, data)
ax.set_title(title, fontsize=23, weight='bold', pad=16)
ax.set_xlabel(label('좌우 위치 (cm)', 'Horizontal position (cm)'), fontsize=15)
ax.set_ylabel(label('앞뒤 위치 (cm)', 'Vertical position (cm)'), fontsize=15)
fig.text(pos[0]+pos[2]/2, .195, f'CV {value:.4f}', ha='center', fontsize=22, weight='bold')
cax = fig.add_axes([.25, .115, .50, .025])
bar = fig.colorbar(im, cax=cax, orientation='horizontal', ticks=[0, 5, 10])
bar.outline.set_visible(False)
bar.ax.tick_params(length=0, labelsize=15)
fig.text(.50, .030, label('정규화 전기장 제곱의 누적량 A (s) · 온도 지도가 아닙니다.',
'Accumulated normalized E², A (s). Not a temperature map.'),
ha='center', fontsize=14, color=MUTED)
save(fig, 'microwave-comparison')
fig = plt.figure(figsize=(11, 8.8))
header(fig, '02 / PATH',
label('회전은 한 점이 만나는 위치를 바꿉니다', 'Rotation changes the positions a point visits'),
label('중심에서 7 cm 떨어진 점을 같은 10초 동안 비교합니다.',
'Compare one point at radius 7 cm over the same ten seconds.'))
ax = fig.add_axes([.065, .30, .40, .48])
field_plot(ax, intensity(x, y), vmax=1)
a = np.linspace(0, 2*np.pi, 361)
ax.plot(7*np.cos(a), 7*np.sin(a), color='white', lw=2.5)
ax.annotate('', xy=(0, 7), xytext=(2.4, 6.58),
arrowprops={'arrowstyle': '-|>', 'color': 'white', 'lw': 2.5, 'mutation_scale': 20})
ax.scatter([7], [0], s=150, color=ORANGE, edgecolors='white', linewidths=2, zorder=5)
ax.scatter([0], [0], s=65, color=INK, edgecolors='white', linewidths=1.8, zorder=5)
ax.text(0, -1.8, label('중심은 제자리', 'Fixed center'), ha='center', fontsize=14,
color='white', bbox={'facecolor': INK, 'edgecolor': 'none', 'pad': 4})
ax.set_xlabel(label('좌우 위치 (cm)', 'Horizontal position (cm)'), fontsize=15)
ax.set_ylabel(label('앞뒤 위치 (cm)', 'Vertical position (cm)'), fontsize=15)
ax.set_title(label('흰 원: 점의 이동 경로', 'White circle: the point’s path'), fontsize=18, pad=16)
graph = fig.add_axes([.57, .33, .36, .42])
t = np.linspace(0, DURATION, 1001)
theta = 2*np.pi*t/DURATION
trace = intensity(.07*np.cos(theta), .07*np.sin(theta))
graph.plot(t, trace, color=BLUE, lw=2.8, label=label('회전', 'Rotating'))
graph.axhline(float(intensity(.07, 0)), color=ORANGE, ls='--', lw=2.3,
label=label('고정', 'Fixed'))
graph.set(xlim=(0, 10), ylim=(0, 1.05), xticks=[0, 5, 10], yticks=[0, .5, 1],
xlabel=label('시간 (s)', 'Time (s)'), ylabel=label('정규화 세기 I', 'Normalized I'))
graph.tick_params(labelsize=15, length=0)
graph.grid(axis='y', color='#d6dcda', linewidth=.8)
graph.legend(frameon=False, fontsize=16, ncol=2, loc='lower center', bbox_to_anchor=(.5, 1.04))
fig.text(.065, .18, label('10초 누적량', 'Exposure over ten seconds'), fontsize=18, weight='bold')
fig.text(.065, .125, label(f'고정 {metrics["path_fixed_exposure"]:.2f} s / 회전 {metrics["path_rotated_exposure"]:.2f} s',
f'Fixed {metrics["path_fixed_exposure"]:.2f} s / Rotating {metrics["path_rotated_exposure"]:.2f} s'),
fontsize=24, color=BLUE, weight='bold')
fig.text(.065, .055, label('배경은 고정된 세기 I의 무늬입니다. 회전한다고 모든 점의 누적량이 늘지는 않습니다.',
'Background: prescribed I pattern. Rotation does not increase every point’s exposure.'),
fontsize=14, color=MUTED)
save(fig, 'microwave-path')
fig = plt.figure(figsize=(11, 8))
header(fig, '03 / LIMIT',
label('한 바퀴를 돌아도 반지름의 차이는 남습니다', 'One full turn leaves differences between radii'),
label('같은 원을 도는 점들은 같아져도, 서로 다른 원의 평균은 다릅니다.',
'Points on the same circle share a mean, but different circles do not.'))
ax = fig.add_axes([.11, .18, .80, .57])
r = np.linspace(0, R, 600)
avg = exposure(r, np.zeros_like(r))
ax.fill_between(r*100, avg, color=BLUE, alpha=.08)
ax.plot(r*100, avg, color=BLUE, lw=3.1)
ax.scatter([0], [10], s=110, color=ORANGE, zorder=4)
ax.annotate(label('움직이지 않는 중심: 10 s', 'Stationary center: 10 s'), (0, 10),
xytext=(1.1, 9.8), fontsize=17, color=ORANGE,
arrowprops={'arrowstyle':'-', 'color':ORANGE, 'lw':1.4})
yr = metrics['path_rotated_exposure']
ax.scatter([7], [yr], s=90, color=BLUE, edgecolors=BG, linewidths=1.5, zorder=4)
ax.annotate(label(f'7 cm 경로: {yr:.2f} s', f'7 cm path: {yr:.2f} s'), (7, yr),
xytext=(6.2, 8.2), fontsize=17, color=BLUE,
arrowprops={'arrowstyle':'-', 'color':BLUE, 'lw':1.4})
ax.set(xlim=(-.35, 11.3), ylim=(0, 10.6), xticks=[0, 2, 4, 6, 8, 10], yticks=[0, 2, 4, 6, 8, 10],
xlabel=label('회전 중심에서 거리 (cm)', 'Distance from center (cm)'),
ylabel=label('누적량 A (s)', 'Exposure A (s)'))
ax.grid(axis='y', color='#d6dcda', linewidth=.8)
ax.tick_params(length=0, labelsize=16)
fig.text(.065, .055, label('이 무늬에서 계산한 결과입니다. 실제 음식의 온도나 최적 배치를 뜻하지 않습니다.',
'Results for this pattern only, not food temperature or an optimal placement rule.'),
fontsize=14, color=MUTED)
save(fig, 'microwave-radius')
def make_video(x, y):
if not FFMpegWriter.isAvailable():
raise RuntimeError('FFmpeg is not available. PNG/SVG/JSON outputs are already saved.')
fig, ax = plt.subplots(figsize=(6, 6), layout='constrained')
im = field_plot(ax, intensity(x, y), vmax=1)
ax.set_title(label('고정 무늬를 지나는 한 점', 'A point moving through a fixed pattern'), fontsize=18)
a = np.linspace(0, 2*np.pi, 200)
ax.plot(7*np.cos(a), 7*np.sin(a), color='white', lw=1.5)
point, = ax.plot([], [], 'o', color=ORANGE, markeredgecolor='white', markersize=9)
note = ax.text(.03, .97, '', transform=ax.transAxes, va='top', fontsize=13,
bbox={'facecolor':BG, 'edgecolor':'none'})
def update(i):
theta = 2*np.pi*i/80
px, py = .07*np.cos(theta), .07*np.sin(theta)
point.set_data([px*100], [py*100])
note.set_text(f'{i/80:.0%} / I = {float(intensity(px, py)):.2f}')
return point, note
FuncAnimation(fig, update, frames=80, interval=100).save(
OUT/'rotation.mp4', writer=FFMpegWriter(fps=10, codec='libx264', extra_args=['-pix_fmt','yuv420p']))
plt.close(fig)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--video', action='store_true')
args = parser.parse_args()
x, y, fixed, rotated, metrics = calculate()
make_figures(x, y, fixed, rotated, metrics)
if args.video:
make_video(x, y)
print(json.dumps(metrics, indent=2))