وقتی زاویهٔ فاز به یک دور محدود می‌شود، عبورِ آرام از مرزِ نمایش می‌تواند جهشی بزرگ در عددِ دیده‌شده بسازد. بازکردنِ فاز، برای انتخابِ یک شاخهٔ پیوسته مفید است؛ اما شمارهٔ دورِ واقعی را از هیچ بازیابی نمی‌کند. این درس، یک تابعِ کوچک را با مسیرهای ساختگی، مرزِ نیم‌دور و شاهدِ ابهام بررسی می‌کند. ورودی از دستگاه یا سیگنالِ اندازه‌گیری‌شده نیست؛ نه فرکانس تخمین می‌زنیم و نه هم‌زمانیِ دو سامانه را تأیید می‌کنیم.

۱. زاویهٔ دیده‌شده و لنگرِ نخستین نمونه

زاویه‌های مشاهده‌شده در یک دور با لنگر نخستین نمونه
تصویر مفهومی: زاویه‌های مشاهده‌شده در یک دور با لنگر نخستین نمونه؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

قراردادِ ما یک دنبالهٔ تک‌بعدی از زاویه‌های رادیانی، در بازهٔ بستهٔ منفی π تا مثبت π است. دو انتها هر دو پذیرفته‌اند، هرچند جهتِ یکسانِ روی دایره را می‌توانند نمایش دهند. خروجی از نخستین مشاهده آغاز می‌شود؛ آن را صفر نمی‌کنیم. بنابراین شاخهٔ نخست، لنگرِ انتخابِ بعدی است و تابع دربارهٔ تعدادِ دورهای پیش از آن اطلاعی ندارد.

مرجعِ unwrap در NumPy، تغییرِ بزرگ‌تر از نصفِ دوره را با مکملِ دوره جابه‌جا می‌کند. این برنامه دوره را ثابتِ ۲π می‌گیرد و تنظیمِ آستانه یا محورِ چندبعدی ندارد. انتخابِ شاخه، باید با فرضی دربارهٔ تغییرِ بینِ مشاهده‌ها تفسیر شود؛ عددی که پس از اصلاح آرام‌تر شده، به‌تنهایی شاهدِ حرکتِ واقعیِ آرام نیست.

نمونهٔ دستی را بر حسبِ یک‌چهارمِ π می‌نویسیم: منفی سه، منفی یک، یک، سه، منفی سه و منفی یک. اگر شاخهٔ افزایشیِ آهسته را فرض کنیم، جواب منفی سه، منفی یک، یک، سه، پنج و هفت است. در گذر از سه به منفی سه، اختلافِ خام منفی شش‌چهارمِ π است و با افزودنِ یک دور، گامِ مثبتِ دوچهارمِ π انتخاب می‌شود.

۲. مرزِ دقیقِ نیم‌دور را پیش از اصلاح تعیین کنیم

دو مسیر هم‌ارز در مرز نیم‌دور
تصویر مفهومی: دو مسیر هم‌ارز در مرز نیم‌دور؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تابعِ ما فقط اگر اختلافِ دو مشاهدهٔ خام از π بزرگ‌تر یا از منفی π کوچک‌تر باشد، شمارِ دور را عوض می‌کند. اختلافِ دقیقِ مثبت یا منفی π را دست نمی‌زند. در نتیجه، ورودیِ صفر و π همان دو مقدار را می‌دهد؛ صفر و منفی π نیز همان جهتِ قراردادیِ منفی را نگه می‌دارد. این تصمیم، کشفِ جهتِ واقعیِ نیم‌دور نیست؛ در آن مرز، اطلاعاتِ کافی برای ترجیحِ یکی از دو مسیر نداریم.

توصیفِ جاریِ unwrap در MathWorks، عبارتِ «بزرگ‌تر یا مساویِ π» را به کار می‌برد، درحالی‌که توصیفِ NumPy از بزرگ‌تر بودن می‌گوید. اینجا MATLAB اجرا نشده و برابریِ رفتاری با آن ادعا نمی‌شود. قراردادِ تابعِ خودمان و آزمونِ دو مرز صریح‌اند؛ از شباهتِ نامِ API، برابریِ همهٔ تنظیمات و جزئیاتِ عددی نتیجه نمی‌گیریم.

مقایسه روی floatهای واقعاً نمایش‌یافته انجام می‌شود. نزدیکِ π، گردشدنِ تفریق می‌تواند شاخهٔ تصمیم را عوض کند؛ افزودنِ یک تلورانسِ پنهان نیز سیاستِ دیگری می‌سازد. در شاهدِ مرزی، نخستین مقدار به‌اندازهٔ math.ulp(math.pi) از صفر جابه‌جا شده تا اختلاف کمی بالا یا پایینِ π قرار گیرد. پاسخِ همان ورودیِ مشخص بررسی می‌شود، نه دقتِ تضمین‌شدهٔ هر زاویهٔ فیزیکیِ نزدیکِ مرز.

۳. کدِ کامل و شمارِ دورِ انتخاب‌شده

افزودن دور کامل به شاخهٔ انتخاب‌شده بدون تغییر زاویهٔ دیده‌شده
تصویر مفهومی: افزودن دور کامل به شاخهٔ انتخاب‌شده بدون تغییر زاویهٔ دیده‌شده؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

کد دو مشاهدهٔ خامِ مجاور را مقایسه می‌کند و یک شمارندهٔ صحیحِ دور نگه می‌دارد. خروجیِ هر عضو، همان مشاهده به‌علاوهٔ شمارِ دور ضرب‌در ۲π است. چون ورودی داخلِ یک دور محدود است، در هر گامِ خام حداکثر یک اصلاحِ دور لازم می‌شود. مقایسهٔ مشاهدهٔ جدید با خروجیِ انباشتهٔ قبلی، بدونِ توجه به همین قرارداد، می‌تواند الگوریتمِ دیگری و خطای چنددوری بسازد.

"""Bounded wrapped-phase branch selection, not absolute-phase recovery."""
import itertools
import json
import math
import platform
import numpy as np


def unwrap_phase(samples, *, unit="rad"):
    if type(unit) is not str or unit != "rad":
        raise ValueError("phase_unit")
    if type(samples) is not tuple or len(samples) > 64:
        raise ValueError("sample_shape_limit")
    values = []
    for value in samples:
        if type(value) not in (int, float) or (type(value) is float and not math.isfinite(value)):
            raise ValueError("phase_type_finite")
        if not -math.pi <= value <= math.pi:
            raise ValueError("wrapped_phase_range")
        values.append(float(value))
    if not values:
        return ()
    output = [values[0]]
    turns = 0
    for previous, current in zip(values, values[1:]):
        delta = current - previous
        if delta > math.pi:
            turns -= 1
        elif delta < -math.pi:
            turns += 1
        # Exact represented +/-pi ties intentionally do not change the branch.
        output.append(current + turns * math.tau)
    return tuple(output)


def wrapped_fixture(known_phases):
    # IEEE remainder chooses a wrapped representation, independently of unwrap_phase.
    return tuple(math.remainder(value, math.tau) for value in known_phases)


def error(call):
    try:
        call()
    except ValueError as exc:
        return str(exc)
    return None


def run_example():
    checks = {}
    def check(name, value):
        checks[name] = bool(value)
        if not checks[name]:
            raise AssertionError(name)
    close = lambda a, b: len(a) == len(b) and all(math.isclose(x, y, rel_tol=0, abs_tol=2e-12) for x, y in zip(a, b))
    known = tuple(k * math.pi / 4 for k in (-3, -1, 1, 3, 5, 7))
    observations = tuple(k * math.pi / 4 for k in (-3, -1, 1, 3, -3, -1))
    result = unwrap_phase(observations)
    check("hand_rising_phase_branch_matches_known_six_values", close(result, known))
    check("hand_falling_phase_branch_preserves_direction", close(unwrap_phase(tuple(-v for v in observations)), tuple(-v for v in known)))
    check("first_observation_is_branch_anchor_not_normalized_to_zero", unwrap_phase((0.2, 1.2))[0] == 0.2)
    check("positive_exact_half_turn_tie_retained", unwrap_phase((0.0, math.pi)) == (0.0, math.pi))
    check("negative_exact_half_turn_tie_retained", unwrap_phase((0.0, -math.pi)) == (0.0, -math.pi))
    tiny = math.ulp(math.pi)
    check("one_ulp_above_and_below_positive_jump_boundary", unwrap_phase((-tiny, math.pi)) == (-tiny, -math.pi)
          and unwrap_phase((tiny, math.pi)) == (tiny, math.pi))
    check("two_endpoint_representations_have_same_selected_branch", unwrap_phase((-math.pi, math.pi)) == (-math.pi, -math.pi)
          and unwrap_phase((math.pi, -math.pi)) == (math.pi, math.pi))
    check("already_small_steps_unchanged", unwrap_phase((0.0, 1.0, 2.0)) == (0.0, 1.0, 2.0))
    repeated_known = (0.0, 2.0, 4.0, 6.0, 8.0, 10.0)
    check("multiple_revolutions_follow_known_slow_steps", close(unwrap_phase(wrapped_fixture(repeated_known)), repeated_known))
    numpy_result = tuple(float(x) for x in np.unwrap(np.asarray(observations, dtype=np.float64)))
    check("recorded_numpy_2_4_crosscheck_on_hand_fixture", close(result, numpy_result))
    count = 0
    for increments in itertools.product((-math.pi / 2, -math.pi / 4, math.pi / 4, math.pi / 2), repeat=4):
        truth = [0.0]
        for increment in increments:
            truth.append(truth[-1] + increment)
        actual = unwrap_phase(wrapped_fixture(truth))
        if not close(actual, truth):
            raise AssertionError("known_small_increment_reference")
        count += 1
    check("256_independent_known_step_paths_one_group", count == 256)
    check("phase_projection_stays_equivalent_modulo_full_turn", all(
        math.isclose(math.cos(x), math.cos(y), abs_tol=2e-12) and math.isclose(math.sin(x), math.sin(y), abs_tol=2e-12)
        for x, y in zip(observations, result)))
    slow_truth, hidden_large_jump = (0.0, math.pi / 2), (0.0, -3 * math.pi / 2)
    same = wrapped_fixture(slow_truth)
    check("alias_witness_distinct_true_paths_share_observations", wrapped_fixture(hidden_large_jump) == same
          and unwrap_phase(same) == slow_truth and not close(unwrap_phase(same), hidden_large_jump))
    restarted = unwrap_phase(observations[:4]) + unwrap_phase(observations[4:])
    check("independent_chunk_restart_loses_prior_turn_offset", not close(restarted, result)
          and close(tuple(x + math.tau for x in restarted[4:]), result[4:]))
    check("empty_singleton_and_64_sample_limit", unwrap_phase(()) == () and unwrap_phase((1,)) == (1.0,)
          and len(unwrap_phase((0.0,) * 64)) == 64
          and error(lambda: unwrap_phase((0.0,) * 65)) == "sample_shape_limit"
          and error(lambda: unwrap_phase([0.0])) == "sample_shape_limit")
    check("exact_numeric_finite_wrapped_range_and_radian_contract", all(error(lambda v=v: unwrap_phase((v,))) == "phase_type_finite" for v in (True, "0", float("nan"), float("inf")))
          and error(lambda: unwrap_phase((10 ** 400,))) == "wrapped_phase_range"
          and error(lambda: unwrap_phase((math.nextafter(math.pi, math.inf),))) == "wrapped_phase_range"
          and error(lambda: unwrap_phase((0,), unit="deg")) == "phase_unit")
    return {"runtime": {"python": platform.python_version(), "numpy": np.__version__},
            "contract": "tuple0..64 finite exact int/float observations in closed[-pi,pi] radians; first observation anchor; correct only raw adjacent delta>pi or<-pi, keep exact ties; no streaming state",
            "hand_observation_quarter_pi_units": [-3, -1, 1, 3, -3, -1],
            "hand_selected_quarter_pi_units": [round(x / (math.pi / 4)) for x in result],
            "known_path_reference_cases_one_group": count, "numpy_hand_crosscheck_rad": list(numpy_result),
            "alias_observations_rad": list(same), "two_possible_true_paths_rad": [list(slow_truth), list(hidden_large_jump)],
            "comparison_absolute_tolerance_rad": 2e-12,
            "limitations": "Branch convention, not absolute phase recovery. Hidden steps of magnitude pi or more and exact half-turn direction are ambiguous without extra assumptions. Floating subtraction near pi affects threshold comparisons; no universal bitwise parity with libraries. MATLAB not run and its current documentation describes >=pi, unlike our strict rule. No timestamp/rate/frequency inference or sensor/network/model execution.",
            "checks": checks}


if __name__ == "__main__":
    print(json.dumps(run_example(), ensure_ascii=False, indent=2))

ورودی باید tuple با صفر تا ۶۴ عضو باشد؛ int یا float دقیقِ متناهی در بازهٔ زاویه پذیرفته می‌شود. bool، رشته، NaN، بی‌نهایت، شکلِ فهرست و مقدارِ بیرونِ بازه رد می‌شوند. واحد دقیقاً rad است و درجه خودکار تبدیل نمی‌شود. ورودیِ تهی، خروجیِ تهی دارد و تک‌عضوی بدونِ تغییرِ شاخه برمی‌گردد. این نمایه، API عمومیِ همهٔ آرایه‌ها یا نوع‌های عددیِ NumPy نیست.

برای ساختنِ بعضی مشاهده‌های آزمون از math.remainder استفاده می‌کنیم؛ مرجعِ math باقیماندهٔ آن را با نزدیک‌ترین عددِ صحیحِ نسبت تعریف می‌کند. این مسیر، از تابعِ بازکردنِ فاز استفاده نمی‌کند. فازِ معلومِ مسیرِ مرجع را ابتدا مستقل می‌سازیم، سپس نمایشِ دوره‌ایِ آن را تولید می‌کنیم؛ خروجیِ تابع، جوابِ موردِ انتظارِ خودش را نمی‌سازد.

۴. مرجعِ مسیرِ معلوم و دو حقیقتِ هم‌مشاهده

مرجع حرکت آهسته و مشاهده‌های دوره‌ای هم‌ارز
تصویر مفهومی: مرجع حرکت آهسته و مشاهده‌های دوره‌ای هم‌ارز؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

۲۵۶ مسیر از چهار گامِ کوچکِ مثبت و منفیِ یک‌چهارم یا نصفِ π، با آغازِ صفر ساخته شده‌اند. قدرِ هر گامِ مرجع از π کوچک‌تر است. این مجموعه یک گروه از شانزده گروهِ بررسی است؛ نمونهٔ دستیِ افزایشی، کاهشی، لنگر، دو انتهای نمایش و چند دورِ پیاپی هم جدا کنترل می‌شوند. برای مقایسهٔ مسیرها، تلورانسِ مطلق دو ضرب‌در ده به توان منفی دوازده رادیان انتخاب شده است.

{
  "runtime": {
    "python": "3.14.5",
    "numpy": "2.4.6"
  },
  "contract": "tuple0..64 finite exact int/float observations in closed[-pi,pi] radians; first observation anchor; correct only raw adjacent delta>pi or<-pi, keep exact ties; no streaming state",
  "hand_observation_quarter_pi_units": [
    -3,
    -1,
    1,
    3,
    -3,
    -1
  ],
  "hand_selected_quarter_pi_units": [
    -3,
    -1,
    1,
    3,
    5,
    7
  ],
  "known_path_reference_cases_one_group": 256,
  "numpy_hand_crosscheck_rad": [
    -2.356194490192345,
    -0.7853981633974483,
    0.7853981633974483,
    2.356194490192345,
    3.9269908169872414,
    5.497787143782138
  ],
  "alias_observations_rad": [
    0.0,
    1.5707963267948966
  ],
  "two_possible_true_paths_rad": [
    [
      0.0,
      1.5707963267948966
    ],
    [
      0.0,
      -4.71238898038469
    ]
  ],
  "comparison_absolute_tolerance_rad": 2e-12,
  "limitations": "Branch convention, not absolute phase recovery. Hidden steps of magnitude pi or more and exact half-turn direction are ambiguous without extra assumptions. Floating subtraction near pi affects threshold comparisons; no universal bitwise parity with libraries. MATLAB not run and its current documentation describes >=pi, unlike our strict rule. No timestamp/rate/frequency inference or sensor/network/model execution.",
  "checks": {
    "hand_rising_phase_branch_matches_known_six_values": true,
    "hand_falling_phase_branch_preserves_direction": true,
    "first_observation_is_branch_anchor_not_normalized_to_zero": true,
    "positive_exact_half_turn_tie_retained": true,
    "negative_exact_half_turn_tie_retained": true,
    "one_ulp_above_and_below_positive_jump_boundary": true,
    "two_endpoint_representations_have_same_selected_branch": true,
    "already_small_steps_unchanged": true,
    "multiple_revolutions_follow_known_slow_steps": true,
    "recorded_numpy_2_4_crosscheck_on_hand_fixture": true,
    "256_independent_known_step_paths_one_group": true,
    "phase_projection_stays_equivalent_modulo_full_turn": true,
    "alias_witness_distinct_true_paths_share_observations": true,
    "independent_chunk_restart_loses_prior_turn_offset": true,
    "empty_singleton_and_64_sample_limit": true,
    "exact_numeric_finite_wrapped_range_and_radian_contract": true
  }
}

برای شاهدِ ابهام، دو مسیرِ حقیقیِ فرضی داریم: صفر به π/۲، و صفر به منفیِ ۳π/۲. مشاهدهٔ دوره‌ایِ هر دو، صفر و π/۲ است. تابع مسیرِ کوتاهِ مثبت را انتخاب می‌کند، اما از خودِ این دو مشاهده نمی‌توان مسیرِ بلندِ منفی را رد کرد. پس یک جوابِ نرم، اثباتِ بازیابیِ حرکت، فازِ مطلق یا شمارِ دورِ واقعی نیست؛ فرضِ گامِ کوچک باید از مسئله و نمونه‌برداری بیاید.

برابریِ سینوس و کسینوسِ مشاهده و خروجی، فقط سازگاریِ دوره‌ای را می‌سنجد؛ مسیرِ غلطِ دارایِ یک دورِ اضافی هم می‌تواند آن آزمون را بگذراند. به همین دلیل، مرجعِ معلوم و شاهدِ ابهام کنارِ آن لازم‌اند. این درس، نویزِ فاز، از‌دست‌رفتنِ نمونه، تأخیرِ دریافت یا گام‌های فیزیکیِ بزرگ را برطرف نمی‌کند و هیچ زمان یا نرخِ نمونه‌برداری در ورودی ندارد.

اگر دنباله را دو قطعه کنیم و هرکدام را مستقل از نو آغاز کنیم، قطعهٔ دوم لنگرِ تازه دارد. در برشِ نمونه پس از عضوِ چهارم، ادامهٔ مستقل یک دور از جوابِ کامل پایین‌تر می‌ماند. تابع حالتِ جریانیِ قابل‌حمل میانِ بسته‌ها ندارد؛ اتصالِ دو خروجی به‌تنهایی آن وضعیت را جبران نمی‌کند. این آزمون، فرقِ برشِ داده با شروعِ دوبارهٔ الگوریتم را روشن می‌سازد.

اجرا با Python 3.14.5 و NumPy 2.4.6 انجام شده؛ np.unwrap فقط برای مقابلهٔ نمونهٔ دستی اجرا شده است. منابعِ NumPy 2.4 و 2.5، پایتون 3.14.8 و MathWorks R2026b در ۲۵ مهر ۱۴۰۵ جدا خوانده شده‌اند. زاویهٔ امپدانسِ RLC به حالتِ ماندگار مربوط بود و علامتِ lag در هم‌بستگی به جابه‌جاییِ نمونه‌ها؛ هیچ‌کدام، مرجعِ فازِ مطلقِ این دنباله نیستند.

۵. چهار پرسش با پاسخِ کامل

اثر آغاز دوبارهٔ قطعه بر شمارهٔ دور انتخابی
تصویر مفهومی: اثر آغاز دوبارهٔ قطعه بر شمارهٔ دور انتخابی؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: دو عضوِ آخرِ نمونهٔ دستی، روی شاخهٔ افزایشی چه می‌شوند؟ پاسخ: پنج‌چهارم و هفت‌چهارمِ π؛ یک دور به مشاهده‌های منفیِ آن دو اضافه می‌شود. چهار عضوِ نخست با همان لنگر باقی می‌مانند.

پرسش دوم: اختلافِ دقیقِ π را تغییر می‌دهیم؟ پاسخ: در قراردادِ صریحِ این تابع خیر. مثبت و منفیِ نیم‌دور حفظ می‌شوند؛ انتخابِ جهتِ واقعی در آن مرز به اطلاعاتِ بیشتری نیاز دارد.

پرسش سوم: مشاهدهٔ صفر و π/۲، مسیرِ کوتاه را ثابت می‌کند؟ پاسخ: خیر؛ مسیرِ صفر تا منفیِ ۳π/۲ همان مشاهده را می‌سازد. تابع یک شاخه انتخاب می‌کند و فرضِ گامِ کوچک را از دادهٔ کافی اثبات نمی‌کند.

پرسش چهارم: چرا اتصالِ خروجیِ دو قطعه ممکن است غلط باشد؟ پاسخ: آغازِ مستقل، شمارِ دور و لنگر را از نو تعیین می‌کند. در نمونهٔ ما ادامه یک دور جابه‌جا می‌شود؛ برای مسیرِ جریانی، باید قراردادِ حملِ حالت طراحی و جدا آزموده شود.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۵ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.