ข้ามไปยังเนื้อหา

บทที่ 10 โครงสร้างโค้ด

ทำไมต้องจัดโครงสร้าง

เมื่อ script ยาวขึ้น main.py ไฟล์เดียวจะบำรุงรักษายากมาก แนะนำแบ่งเป็น module แยกตามหน้าที่:

%%{init: {
  "theme": "base",
  "flowchart": {
    "curve": "basis",
    "htmlLabels": true,
    "nodeSpacing": 42,
    "rankSpacing": 56,
    "padding": 16
  }
}}%%
flowchart TD
    M["main.py<br/>Entry point"]
    C["core/<br/>EtabsSession"]
    MO["models/<br/>Data classes"]
    S["services/<br/>Business logic"]
    M --> C
    M --> S
    S --> C
    S --> MO

โครงสร้างที่แนะนำ

MyEtabsApp/
├── main.py ← Entry point
├── requirements.txt
├── core/
│ ├── __init__.py
│ ├── session.py ← EtabsSession (COM lifecycle)
│ └── helpers.py ← check(), utils
├── models/
│ ├── __init__.py
│ ├── frame_result.py ← FrameForceResult dataclass
│ ├── joint_info.py ← JointInfo dataclass
│ └── story_info.py ← StoryInfo dataclass
├── services/
│ ├── __init__.py
│ ├── model_reader.py ← อ่านข้อมูลโมเดล
│ ├── analyzer.py ← รัน analysis
│ └── result_exporter.py ← export results → CSV/Excel
├── tests/
│ ├── __init__.py
│ ├── test_helpers.py
│ └── test_result_exporter.py
└── output/ ← CSV, Excel output

Layer 1: core/ (COM Session)

core/session.py

"""จัดการ ETABS COM connection และ lifecycle"""
import comtypes.client
import gc
class EtabsSession:
"""Context manager สำหรับ ETABS COM session"""
_PROG_ID = "CSI.ETABS.API.ETABSObject"
def __init__(self, mode: str = "attach"):
self.mode = mode
self._etabs = None
self._sap_model = None
@property
def sap_model(self):
if self._sap_model is None:
raise ConnectionError("ยังไม่ได้ connect — ใช้ with statement")
return self._sap_model
def connect(self):
if self.mode == "attach":
self._etabs = comtypes.client.GetActiveObject(self._PROG_ID)
else:
self._etabs = comtypes.client.CreateObject(self._PROG_ID)
self._etabs.ApplicationStart()
if self._etabs is None:
raise ConnectionError("ไม่สามารถเชื่อม ETABS ได้")
self._sap_model = self._etabs.SapModel
def close(self):
if self._sap_model is not None:
del self._sap_model
self._sap_model = None
if self._etabs is not None:
del self._etabs
self._etabs = None
gc.collect()
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False

core/helpers.py

"""Helper functions สำหรับ ETABS API"""
def check(ret: int, method_name: str) -> int:
"""ตรวจ return code จาก API call"""
if ret != 0:
raise RuntimeError(f"{method_name} failed (ret={ret})")
return ret

Layer 2: models/ (Data Classes)

ใช้ @dataclass แทน plain dict เพื่อ:

  • Type hints ชัดเจน
  • IDE auto-complete
  • ง่ายต่อการ serialize

models/frame_result.py

from dataclasses import dataclass
@dataclass
class FrameForceResult:
"""ผลลัพธ์ Frame Force สำหรับ 1 station"""
frame: str
station: float
load_case: str
p: float # axial force (kN)
v2: float # shear V2 (kN)
v3: float # shear V3 (kN)
t: float # torsion (kN·m)
m2: float # moment M2 (kN·m)
m3: float # moment M3 (kN·m)
@property
def max_abs_moment(self) -> float:
return max(abs(self.m2), abs(self.m3))

models/joint_info.py

from dataclasses import dataclass
@dataclass
class JointInfo:
"""ข้อมูล Joint"""
name: str
x: float
y: float
z: float

Layer 3: services/ (Business Logic)

services/model_reader.py

"""บริการอ่านข้อมูลจากโมเดล ETABS"""
from core.helpers import check
from models.joint_info import JointInfo
class ModelReader:
"""อ่านข้อมูลจาก ETABS model"""
def __init__(self, sap_model):
self._model = sap_model
def get_frame_names(self) -> list[str]:
"""อ่านรายชื่อ frame ทั้งหมด"""
ret, _, names = self._model.FrameObj.GetNameList()
check(ret, "FrameObj.GetNameList")
return list(names)
def get_joints(self) -> list[JointInfo]:
"""อ่านพิกัด joints ทั้งหมด"""
ret, _, point_names = self._model.PointObj.GetNameList()
check(ret, "PointObj.GetNameList")
joints = []
for name in point_names:
ret, x, y, z = self._model.PointObj.GetCoordCartesian(name)
check(ret, f"GetCoordCartesian({name})")
joints.append(JointInfo(name=name, x=x, y=y, z=z))
return joints

services/result_exporter.py

"""Export results เป็น CSV/Excel"""
import pandas as pd
from models.frame_result import FrameForceResult
class ResultExporter:
"""Export ETABS results ผ่าน pandas"""
@staticmethod
def to_csv(results: list[FrameForceResult], filepath: str):
"""Export FrameForceResult list เป็น CSV"""
df = pd.DataFrame([r.__dict__ for r in results])
df.to_csv(filepath, index=False)
print(f"✅ Export {filepath} ({len(results)} rows)")
@staticmethod
def to_excel(
results: list[FrameForceResult],
filepath: str,
sheet_name: str = "FrameForces"
):
"""Export เป็น Excel (.xlsx)"""
df = pd.DataFrame([r.__dict__ for r in results])
df.to_excel(filepath, sheet_name=sheet_name, index=False)
print(f"✅ Export {filepath}")

Layer 0: main.py (Entry Point)

"""Entry point — ทุกอย่างเริ่มจากที่นี่"""
from core.session import EtabsSession
from services.model_reader import ModelReader
from services.result_exporter import ResultExporter
def main():
with EtabsSession() as session:
model = session.sap_model
# Set units
model.SetPresentUnits(6)
# อ่านข้อมูล
reader = ModelReader(model)
frame_names = reader.get_frame_names()
joints = reader.get_joints()
print(f"🏗️ Frames: {len(frame_names)}")
print(f"📍 Joints: {len(joints)}")
# Analyze
model.Analyze.RunAnalysis()
# Export
# ... (ดูบทที่ 7 สำหรับการอ่าน results)
print("✅ เสร็จ!")
if __name__ == "__main__":
main()

Type Hints

ใช้ type hints ทุกที่เพื่อให้ IDE ช่วยตรวจ error:

# ❌ ไม่มี type hints
def get_frames(model):
ret, count, names = model.FrameObj.GetNameList()
return names
# ✅ มี type hints
def get_frames(model) -> list[str]:
ret: int
count: int
names: tuple[str, ...]
ret, count, names = model.FrameObj.GetNameList()
return list(names)

Testing ด้วย pytest

ติดตั้ง

Terminal window
pip install pytest

tests/test_helpers.py

"""Test helper functions"""
import pytest
from core.helpers import check
def test_check_success():
"""ret = 0 ต้องผ่าน"""
assert check(0, "TestMethod") == 0
def test_check_failure():
"""ret != 0 ต้อง raise RuntimeError"""
with pytest.raises(RuntimeError, match="TestMethod failed"):
check(1, "TestMethod")

tests/test_result_exporter.py

"""Test result exporter (ไม่ต้องเชื่อม ETABS)"""
import os
import pytest
from models.frame_result import FrameForceResult
from services.result_exporter import ResultExporter
@pytest.fixture
def sample_results() -> list[FrameForceResult]:
return [
FrameForceResult("B1", 0.0, "DEAD", 0, 25.5, 0, 0, 0, -12.3),
FrameForceResult("B1", 0.5, "DEAD", 0, 0, 0, 0, 0, 15.8),
FrameForceResult("B1", 1.0, "DEAD", 0, -25.5, 0, 0, 0, -12.3),
]
def test_export_csv(sample_results, tmp_path):
filepath = str(tmp_path / "test.csv")
ResultExporter.to_csv(sample_results, filepath)
assert os.path.exists(filepath)
def test_max_abs_moment():
r = FrameForceResult("B1", 0, "DEAD", 0, 0, 0, 0, -5.0, 10.0)
assert r.max_abs_moment == 10.0

รัน Test

Terminal window
# รัน test ทั้งหมด
pytest tests/ -v
# รัน test เฉพาะไฟล์
pytest tests/test_helpers.py -v

Dependency Diagram

%%{init: {
  "theme": "base",
  "flowchart": {
    "curve": "basis",
    "htmlLabels": true,
    "nodeSpacing": 42,
    "rankSpacing": 56,
    "padding": 16
  }
}}%%
flowchart TD
    subgraph Entry["Entry Point"]
        MAIN["main.py"]
    end
    subgraph Services["services/"]
        MR["model_reader.py"]
        RE["result_exporter.py"]
        AN["analyzer.py"]
    end
    subgraph Core["core/"]
        SS["session.py"]
        HP["helpers.py"]
    end
    subgraph Models["models/"]
        FR["frame_result.py"]
        JI["joint_info.py"]
    end

    MAIN --> SS
    MAIN --> MR
    MAIN --> RE
    MR --> HP
    MR --> JI
    RE --> FR
    AN --> HP

สรุป

ชั้นหน้าที่ตัวอย่างไฟล์
main.pyEntry point, orchestrationmain.py
core/COM session, helperssession.py, helpers.py
models/Data classes (@dataclass)frame_result.py
services/Business logicmodel_reader.py, result_exporter.py
tests/Unit tests (pytest)test_helpers.py