교실 자동 냉난방·환기 시스템
센서 6개로 장치 4개를 제어하는 논리 회로. and·or·not 세 연산만으로 설계해 진리표 64줄로 검증했고, tkinter 프로그램을 브라우저 시뮬레이터로 옮겼습니다.
원본 프로그램(tkinter)
classroom_hvac.py · 19.4 KB · 2026-10-01
Python 표준 라이브러리만 씁니다.
"""교실 자동 냉난방/환기 시스템 (입력 6개 / 출력 4개)
05_invention_starter_2out.py 를 복사해 '학생이 수정하는 부분'만 고친 파일입니다.
GUI 프레임워크는 템플릿 그대로 두었습니다.
입력 R: 자동 운영 모드가 켜져 있는가?
P: 교실에 사람이 있는가?
W: 창문이 열렸는가?
T1: 외부 온도가 28도를 초과하는가?
T2: 외부 온도가 18도 미만인가?
Q: 이산화탄소 농도가 높은가?
출력 C: 냉방, H: 난방, A1: 창문 닫기 경고, A2: 창문 열기 경고
판단 규칙
hot = T1 and (not T2) 더위 신호가 믿을 만한가
cold = T2 and (not T1) 추위 신호가 믿을 만한가
C = R and P and (not W) and hot and (not Q)
H = R and P and (not W) and cold and (not Q)
A1 = R and P and W and (hot or cold) and (not Q)
A2 = P and (not W) and Q
두 경고음은 창문 상태가 서로 반대입니다.
닫으라는 경고는 열려 있을 때만, 열라는 경고는 닫혀 있을 때만 울립니다.
불가능한 입력 처리
바깥 온도가 28도를 넘으면서 동시에 18도 미만일 수는 없습니다.
T1 과 T2 가 함께 참이면 온도 센서가 고장 난 것으로 보고
온도로 움직이는 출력(C, H, A1)을 모두 멈춥니다.
화면에서도 두 체크박스가 동시에 켜지지 않도록 막습니다.
수동 조작
자동 운영 모드(R)가 꺼지면 사람이 냉방과 난방을 직접 켜고 끕니다.
R 을 다시 켜면 자동 판단이 되찾아가고 수동 스위치는 잠깁니다.
C = (R and 자동냉방) or ((not R) and 수동냉방)
H = (R and 자동난방) or ((not R) and 수동난방)
R 이 선택 신호로 동작하는 2:1 멀티플렉서와 같은 모양입니다.
경고음은 안전 장치라 수동 모드에서도 자동 판단을 그대로 씁니다.
진리표(TEST_CASES)는 자동 운영 논리만 담습니다.
실행 방법:
python classroom_hvac.py
"""
import itertools
import tkinter as tk
from tkinter import messagebox, ttk
# ============================================================
# 학생이 수정하는 부분
# ============================================================
TITLE = "교실 자동 냉난방/환기 시스템"
DESCRIPTION = "센서를 켜고 끄면 네 출력 장치의 상태가 즉시 갱신됩니다."
INPUT_LABELS = [
"자동 운영 모드가 켜져 있습니다 (R)",
"교실에 사람이 있습니다 (P)",
"창문이 열렸습니다 (W)",
"외부 온도가 28도를 초과합니다 (T1)",
"외부 온도가 18도 미만입니다 (T2)",
"이산화탄소 농도가 높습니다 (Q)",
]
# 동시에 참일 수 없는 센서 묶음. INPUT_LABELS 의 번호로 적습니다.
# 3번 T1(28도 초과)과 4번 T2(18도 미만)는 물리적으로 함께 성립할 수 없으므로,
# 하나를 켜면 다른 하나가 자동으로 꺼집니다.
EXCLUSIVE_INPUTS = [(3, 4)]
# 출력 장치 네 개의 이름과 상태 문구 (순서를 맞춥니다)
OUTPUT_LABELS = ["냉방 (C)", "난방 (H)", "창문 닫기 경고 (A1)", "창문 열기 경고 (A2)"]
MESSAGES_ON = [
"실내가 더워 냉방을 가동합니다.",
"실내가 추워 난방을 가동합니다.",
"냉난방 효율을 위해 창문을 닫아 주세요.",
"이산화탄소 농도가 높습니다. 창문을 여세요.",
]
MESSAGES_OFF = [
"냉방은 멈춰 있습니다.",
"난방은 멈춰 있습니다.",
"창문 닫기 경고음은 울리지 않습니다.",
"공기 상태는 정상입니다.",
]
def decide(auto_mode, person, window_open, too_hot, too_cold, co2_high):
"""판단 규칙: 네 출력의 목적이 다르므로 조건식도 다릅니다."""
# 28도 초과이면서 동시에 18도 미만일 수는 없습니다.
# 두 신호가 함께 들어오면 센서 고장으로 보고 온도 판단을 하지 않습니다.
hot = too_hot and not too_cold
cold = too_cold and not too_hot
# 냉난방은 창문이 닫혀 있어야 의미가 있고, 환기가 먼저인 상황에서는 멈춥니다.
cooling = auto_mode and person and not window_open and hot and not co2_high
heating = auto_mode and person and not window_open and cold and not co2_high
# 창문이 열려 있을 때만 닫으라고 알립니다. 이미 닫혀 있으면 알릴 이유가 없습니다.
close_warning = auto_mode and person and window_open and (hot or cold) and not co2_high
# 공기 질은 안전 문제이므로 자동 운영 모드와 상관없이 경고합니다.
# 다만 창문이 이미 열려 있으면 열라고 할 이유가 없습니다.
open_warning = person and not window_open and co2_high
return cooling, heating, close_warning, open_warning
# 자동 운영 모드가 꺼졌을 때 사람이 직접 켜고 끄는 스위치
MANUAL_LABELS = ["수동 냉방", "수동 난방"]
# 두 스위치는 동시에 켤 수 없습니다. 냉방과 난방을 함께 돌릴 수는 없기 때문입니다.
EXCLUSIVE_MANUAL = [(0, 1)]
def final_outputs(sensors, manual):
"""자동 운영 모드면 decide()의 판단을, 아니면 수동 스위치를 따릅니다.
R 이 선택 신호로 동작하는 2:1 멀티플렉서와 같은 모양입니다.
C = (R and 자동냉방) or ((not R) and 수동냉방)
경고음은 안전 장치이므로 수동 모드에서도 자동 판단을 그대로 씁니다.
A1 은 조건에 R 이 들어 있어 수동 모드에서는 저절로 꺼지고,
A2 는 R 과 무관하므로 수동 모드에서도 공기가 나빠지면 울립니다.
"""
auto_mode = sensors[0]
cooling_auto, heating_auto, close_warning, open_warning = decide(*sensors)
manual_cooling, manual_heating = manual
cooling = (auto_mode and cooling_auto) or (not auto_mode and manual_cooling)
heating = (auto_mode and heating_auto) or (not auto_mode and manual_heating)
return cooling, heating, close_warning, open_warning
# 제품 시험표: ((R, P, W, T1, T2, Q), (C, H, A1, A2))
# 자동 운영 모드의 논리만 담습니다. 수동 조작은 사람이 결정하므로 진리표에 넣지 않습니다.
# 진리표 64줄을 그대로 옮겨 적었습니다. 0 은 False, 1 은 True 를 뜻합니다.
TEST_CASES = [
((0, 0, 0, 0, 0, 0), (0, 0, 0, 0)),
((0, 0, 0, 0, 0, 1), (0, 0, 0, 0)),
((0, 0, 0, 0, 1, 0), (0, 0, 0, 0)),
((0, 0, 0, 0, 1, 1), (0, 0, 0, 0)),
((0, 0, 0, 1, 0, 0), (0, 0, 0, 0)),
((0, 0, 0, 1, 0, 1), (0, 0, 0, 0)),
((0, 0, 0, 1, 1, 0), (0, 0, 0, 0)),
((0, 0, 0, 1, 1, 1), (0, 0, 0, 0)),
((0, 0, 1, 0, 0, 0), (0, 0, 0, 0)),
((0, 0, 1, 0, 0, 1), (0, 0, 0, 0)),
((0, 0, 1, 0, 1, 0), (0, 0, 0, 0)),
((0, 0, 1, 0, 1, 1), (0, 0, 0, 0)),
((0, 0, 1, 1, 0, 0), (0, 0, 0, 0)),
((0, 0, 1, 1, 0, 1), (0, 0, 0, 0)),
((0, 0, 1, 1, 1, 0), (0, 0, 0, 0)),
((0, 0, 1, 1, 1, 1), (0, 0, 0, 0)),
((0, 1, 0, 0, 0, 0), (0, 0, 0, 0)),
((0, 1, 0, 0, 0, 1), (0, 0, 0, 1)),
((0, 1, 0, 0, 1, 0), (0, 0, 0, 0)),
((0, 1, 0, 0, 1, 1), (0, 0, 0, 1)),
((0, 1, 0, 1, 0, 0), (0, 0, 0, 0)),
((0, 1, 0, 1, 0, 1), (0, 0, 0, 1)),
((0, 1, 0, 1, 1, 0), (0, 0, 0, 0)),
((0, 1, 0, 1, 1, 1), (0, 0, 0, 1)),
((0, 1, 1, 0, 0, 0), (0, 0, 0, 0)),
((0, 1, 1, 0, 0, 1), (0, 0, 0, 0)),
((0, 1, 1, 0, 1, 0), (0, 0, 0, 0)),
((0, 1, 1, 0, 1, 1), (0, 0, 0, 0)),
((0, 1, 1, 1, 0, 0), (0, 0, 0, 0)),
((0, 1, 1, 1, 0, 1), (0, 0, 0, 0)),
((0, 1, 1, 1, 1, 0), (0, 0, 0, 0)),
((0, 1, 1, 1, 1, 1), (0, 0, 0, 0)),
((1, 0, 0, 0, 0, 0), (0, 0, 0, 0)),
((1, 0, 0, 0, 0, 1), (0, 0, 0, 0)),
((1, 0, 0, 0, 1, 0), (0, 0, 0, 0)),
((1, 0, 0, 0, 1, 1), (0, 0, 0, 0)),
((1, 0, 0, 1, 0, 0), (0, 0, 0, 0)),
((1, 0, 0, 1, 0, 1), (0, 0, 0, 0)),
((1, 0, 0, 1, 1, 0), (0, 0, 0, 0)),
((1, 0, 0, 1, 1, 1), (0, 0, 0, 0)),
((1, 0, 1, 0, 0, 0), (0, 0, 0, 0)),
((1, 0, 1, 0, 0, 1), (0, 0, 0, 0)),
((1, 0, 1, 0, 1, 0), (0, 0, 0, 0)),
((1, 0, 1, 0, 1, 1), (0, 0, 0, 0)),
((1, 0, 1, 1, 0, 0), (0, 0, 0, 0)),
((1, 0, 1, 1, 0, 1), (0, 0, 0, 0)),
((1, 0, 1, 1, 1, 0), (0, 0, 0, 0)),
((1, 0, 1, 1, 1, 1), (0, 0, 0, 0)),
((1, 1, 0, 0, 0, 0), (0, 0, 0, 0)),
((1, 1, 0, 0, 0, 1), (0, 0, 0, 1)),
((1, 1, 0, 0, 1, 0), (0, 1, 0, 0)),
((1, 1, 0, 0, 1, 1), (0, 0, 0, 1)),
((1, 1, 0, 1, 0, 0), (1, 0, 0, 0)),
((1, 1, 0, 1, 0, 1), (0, 0, 0, 1)),
((1, 1, 0, 1, 1, 0), (0, 0, 0, 0)),
((1, 1, 0, 1, 1, 1), (0, 0, 0, 1)),
((1, 1, 1, 0, 0, 0), (0, 0, 0, 0)),
((1, 1, 1, 0, 0, 1), (0, 0, 0, 0)),
((1, 1, 1, 0, 1, 0), (0, 0, 1, 0)),
((1, 1, 1, 0, 1, 1), (0, 0, 0, 0)),
((1, 1, 1, 1, 0, 0), (0, 0, 1, 0)),
((1, 1, 1, 1, 0, 1), (0, 0, 0, 0)),
((1, 1, 1, 1, 1, 0), (0, 0, 0, 0)),
((1, 1, 1, 1, 1, 1), (0, 0, 0, 0)),
]
# ============================================================
# GUI 프레임워크: 처음에는 수정하지 않습니다
# ============================================================
INPUT_COUNT = len(INPUT_LABELS)
OUTPUT_COUNT = len(OUTPUT_LABELS)
TEXT_ON = "작동"
TEXT_OFF = "정지"
FG_ON, BG_ON = "#b71c1c", "#ffe0e0"
FG_OFF, BG_OFF = "#1b5e20", "#e3f1e3"
def bits(values) -> str:
"""(True, False) 를 '1 0' 처럼 0과 1로 나타냅니다."""
return " ".join(str(int(value)) for value in values)
def all_input_cases():
"""입력 개수에 맞는 모든 조합을 순서대로 만들어 줍니다."""
return list(itertools.product((False, True), repeat=INPUT_COUNT))
def outputs_of(values):
"""decide()의 결과를 항상 길이 4의 튜플로 정리합니다."""
result = decide(*values)
if not isinstance(result, tuple) or len(result) != OUTPUT_COUNT:
raise ValueError(
"decide 함수는 출력 {}개를 튜플로 돌려주어야 합니다.".format(
OUTPUT_COUNT
)
)
return tuple(bool(item) for item in result)
def check_test_cases() -> str:
"""TEST_CASES의 형식을 검사합니다. 문제가 없으면 빈 문자열을 돌려줍니다."""
if not TEST_CASES:
return "TEST_CASES가 비어 있습니다.\n네 출력의 예상값을 먼저 채우세요."
for number, case in enumerate(TEST_CASES, start=1):
if not isinstance(case, tuple) or len(case) != 2:
return "{}번째 테스트 사례는 ((입력들), (예상출력들)) 모양이어야 합니다.".format(
number
)
inputs, expected = case
if not isinstance(inputs, tuple) or len(inputs) != INPUT_COUNT:
return "{}번째 테스트 사례의 입력이 {}개가 아닙니다.".format(
number, INPUT_COUNT
)
if not isinstance(expected, tuple) or len(expected) != OUTPUT_COUNT:
return "{}번째 예상 출력이 {}개가 아닙니다.".format(
number, OUTPUT_COUNT
)
return ""
def run_tests():
"""모든 테스트 사례를 실행하고 (통과 수, 전체 수, 실패 목록)을 돌려줍니다."""
passed = 0
failures = []
for inputs, expected in TEST_CASES:
actual = outputs_of(inputs)
if tuple(bool(item) for item in expected) == actual:
passed += 1
else:
failures.append(
"입력 ({}) : 예상 ({}), 실제 ({})".format(
bits(inputs), bits(expected), bits(actual)
)
)
return passed, len(TEST_CASES), failures
def test_report() -> str:
"""전체 테스트 결과를 사람이 읽을 수 있는 문장으로 만듭니다."""
problem = check_test_cases()
if problem:
return problem
try:
passed, total, failures = run_tests()
except ValueError as error:
return str(error)
lines = ["테스트 결과: {} / {} 통과".format(passed, total)]
lines.append("출력 순서: {}".format(", ".join(OUTPUT_LABELS)))
if failures:
lines.append("")
lines.append("실패한 입력 조합")
lines.extend(failures)
lines.append("")
lines.append("네 출력의 조건식을 각각 다시 확인해 보세요.")
else:
lines.append("")
lines.append("모든 입력 조합에서 네 출력이 의도한 대로 작동합니다.")
return "\n".join(lines)
def open_truth_table(parent) -> None:
"""전체 입력 조합에 대한 네 출력의 예상값과 실제값을 보여 주는 창입니다."""
expected_of = dict(TEST_CASES) if not check_test_cases() else {}
window = tk.Toplevel(parent)
window.title("전체 입력 테스트")
frame = ttk.Frame(window, padding=14)
frame.pack(fill="both", expand=True)
headers = ["입력 {}".format(i + 1) for i in range(INPUT_COUNT)]
headers += ["예상", "실제", "일치"]
for column, text in enumerate(headers):
ttk.Label(frame, text=text, font=("TkDefaultFont", 10, "bold")).grid(
row=0, column=column, padx=6, pady=(0, 6)
)
cases = all_input_cases()
for row, values in enumerate(cases, start=1):
actual = outputs_of(values)
expected = expected_of.get(values)
for column, value in enumerate(values):
ttk.Label(frame, text=str(int(value))).grid(row=row, column=column)
ttk.Label(
frame, text="-" if expected is None else bits(expected)
).grid(row=row, column=INPUT_COUNT)
ttk.Label(frame, text=bits(actual)).grid(
row=row, column=INPUT_COUNT + 1
)
if expected is None:
mark = "?"
elif tuple(bool(item) for item in expected) == actual:
mark = "O"
else:
mark = "X"
ttk.Label(frame, text=mark).grid(row=row, column=INPUT_COUNT + 2)
note = "출력 순서: {}".format(", ".join(OUTPUT_LABELS))
ttk.Label(frame, text=note).grid(
row=len(cases) + 1, column=0, columnspan=INPUT_COUNT + 3, pady=(10, 0)
)
ttk.Button(frame, text="닫기", command=window.destroy).grid(
row=len(cases) + 2, column=0, columnspan=INPUT_COUNT + 3, pady=(8, 0)
)
def main() -> None:
root = tk.Tk()
root.title(TITLE)
outer = ttk.Frame(root, padding=16)
outer.pack(fill="both", expand=True)
ttk.Label(
outer, text=TITLE, font=("TkDefaultFont", 14, "bold")
).pack(anchor="w")
ttk.Label(outer, text=DESCRIPTION).pack(anchor="w", pady=(2, 10))
sensor_box = ttk.LabelFrame(outer, text="가상 센서 입력", padding=10)
sensor_box.pack(fill="x")
sensor_vars = [tk.BooleanVar(value=False) for _ in INPUT_LABELS]
manual_box = ttk.LabelFrame(
outer, text="수동 조작 (자동 운영 모드가 꺼졌을 때만)", padding=10
)
manual_box.pack(fill="x", pady=(10, 0))
manual_vars = [tk.BooleanVar(value=False) for _ in MANUAL_LABELS]
manual_checks = []
output_box = ttk.LabelFrame(outer, text="출력 장치", padding=10)
output_box.pack(fill="x", pady=(10, 0))
state_labels = []
message_labels = []
for index, label in enumerate(OUTPUT_LABELS):
ttk.Label(output_box, text=label + " :").grid(
row=index * 2, column=0, sticky="w", pady=(0 if index == 0 else 8, 0)
)
chip = tk.Label(
output_box, text=TEXT_OFF, width=8, relief="ridge", padx=6, pady=2
)
chip.grid(
row=index * 2,
column=1,
sticky="w",
padx=8,
pady=(0 if index == 0 else 8, 0),
)
message = ttk.Label(output_box, text=MESSAGES_OFF[index])
message.grid(row=index * 2 + 1, column=0, columnspan=2, sticky="w")
state_labels.append(chip)
message_labels.append(message)
status_label = ttk.Label(outer, text="")
def update_output(*_args) -> None:
"""센서와 수동 스위치가 바뀔 때마다 네 출력을 함께 갱신합니다."""
values = [var.get() for var in sensor_vars]
manual = [var.get() for var in manual_vars]
try:
outputs_of(values) # decide()가 출력 개수를 맞게 돌려주는지 먼저 확인
results = final_outputs(values, manual)
except ValueError as error:
status_label.config(text=str(error))
return
# 자동 운영 모드가 켜져 있으면 수동 스위치는 잠급니다.
auto_mode = values[0]
for check in manual_checks:
check.state(["disabled"] if auto_mode else ["!disabled"])
for index, result in enumerate(results):
state_labels[index].config(
text=TEXT_ON if result else TEXT_OFF,
fg=FG_ON if result else FG_OFF,
bg=BG_ON if result else BG_OFF,
)
message_labels[index].config(
text=MESSAGES_ON[index] if result else MESSAGES_OFF[index]
)
status_label.config(
text="[{} 모드] 입력 {} -> 출력 {}".format(
"자동" if auto_mode else "수동", bits(values), bits(results)
)
)
def make_toggle(index):
"""센서를 켤 때, 함께 참일 수 없는 센서는 자동으로 끕니다."""
def on_toggle() -> None:
if sensor_vars[index].get():
for group in EXCLUSIVE_INPUTS:
if index in group:
for other in group:
if other != index:
sensor_vars[other].set(False)
update_output()
return on_toggle
for row, (label, var) in enumerate(zip(INPUT_LABELS, sensor_vars)):
ttk.Checkbutton(
sensor_box, text=label, variable=var, command=make_toggle(row)
).grid(row=row, column=0, sticky="w", pady=1)
def make_manual_toggle(index):
"""수동 스위치 하나를 켜면 함께 켤 수 없는 스위치는 끕니다."""
def on_toggle() -> None:
if manual_vars[index].get():
for group in EXCLUSIVE_MANUAL:
if index in group:
for other in group:
if other != index:
manual_vars[other].set(False)
update_output()
return on_toggle
for row, (label, var) in enumerate(zip(MANUAL_LABELS, manual_vars)):
check = ttk.Checkbutton(
manual_box, text=label, variable=var, command=make_manual_toggle(row)
)
check.grid(row=row, column=0, sticky="w", pady=1)
manual_checks.append(check)
button_box = ttk.Frame(outer)
button_box.pack(fill="x", pady=(12, 0))
def reset_sensors() -> None:
for var in sensor_vars:
var.set(False)
for var in manual_vars:
var.set(False)
update_output()
ttk.Button(button_box, text="초기화", command=reset_sensors).pack(
side="left"
)
ttk.Button(
button_box,
text="전체 테스트",
command=lambda: messagebox.showinfo("전체 테스트", test_report()),
).pack(side="left", padx=6)
ttk.Button(
button_box, text="진리표 보기", command=lambda: open_truth_table(root)
).pack(side="left")
ttk.Button(button_box, text="종료", command=root.destroy).pack(side="right")
status_label.pack(anchor="w", pady=(10, 0))
update_output()
root.resizable(False, False)
root.mainloop()
if __name__ == "__main__":
main()