조립품에 들어가는 부품이 수십 개만 넘어가도 하나씩 Measure Body를 실행해 질량을 기록하는 방식은 누락을 확인하기 어렵습니다. 같은 부품이 세 번 들어갔는데 목록에는 한 번만 적거나, 밀도가 지정되지 않은 부품을 정상 질량에 합산하면 BOM과 실제 설계 결과가 어긋날 수 있습니다.
이 글에서는 NX Open Python Journal로 조립품의 발생 부품(Occurrence)을 순회하면서 질량·부피·유효 밀도를 CSV로 내보내는 실습을 다룹니다.
그리고 부품 형상을 변경하지 않고 물리 재질 누락, 미로딩 부품, 억제된 부품을 별도 상태로 남기도록 구성했습니다.
실행 검증 범위
아래 Journal은 공개 NX Open API에 맞춰 제작하고 Python 구문 검사까지 완료한 검증용 예제입니다. 이 환경에서는 Siemens NX 프로그램으로 실제 실행하거나 NX 버전별 결과를 측정하지 않았습니다. 수치가 기재된 표와 CSV는 독립 계산한 가상 기대값입니다. 최초 실행은 백업된 비기밀 테스트 어셈블리에서 진행하세요.
NX Journal 질량 CSV 자동화의 목표
핵심은 단순히 질량 숫자를 빼내는 것이 아니라 어떤 부품에서 어떤 조건으로 구한 값인지 재검증할 수 있도록 출력하는 것입니다. 따라서 계산에 실패한 행을 지우지 않고 상태 코드와 사유를 남깁니다.
STEP 색상이나 부품 이름만으로 NX 물리 재질이 자동 지정되었다고 판단하면 안 됩니다.
NX로 가져온 후 A 부품에는 지정한 알루미늄 재질, B에는 강철 재질을 각각 할당하고 C는 재질 미지정으로 남겨 둡니다.
정확히 동일한 2,700·7,800 kg/m³ 값을 가진 라이브러리 재질 또는 검증된 테스트 재질을 사용해야 기대값과 비교할 수 있습니다.
NX STEP 가져오기 옵션에 따라 원래의 어셈블리 구조가 개별 파트 대신 다중 솔리드 단일 Part로 들어올 수 있습니다.
Assembly Navigator에 A 두 번, B 한 번, C 한 번으로 4개 Occurrence가 보이는지 먼저 점검하세요. 그렇지 않으면 파일을 개별 파트로 나누어 어셈블리에 배치하는 방식으로 샘플을 구성해야 합니다.
NX Journal 실행 및 CSV 저장
1. 읽기 전용 대상 파일 열기
실행 전 파일을 복제하고, 원본 모델의 저장 상태 및 물리 재질을 확인합니다.
테스트 어셈블리를 NX에서 엽니다. 억제된 부품·미로딩 부품이 있다면 작업 상태를 기록합니다.
가능하면 완전 로딩 상태에서 시작합니다. 코드가 부품을 자동 로드하거나 억제를 변경하지 않도록 설계되어 있습니다.
2. Python Journal 실행
NX에서 Tools → Journal → Play에 해당하는 저널 재생 메뉴를 열고 아래 Python 파일을 지정합니다. UI 위치와 표기는 릴리스·리본 사용자화에 따라 다를 수 있습니다.
Python Journal 파일
NX_Journal_부품별_질량_밀도_CSV_검증용.py
코드 본문은 아래 접기 영역에서 모두 복사할 수 있습니다. 배포용 .py 파일은 WordPress 미디어 업로드 후 실제 다운로드 URL을 연결하세요.
파일의 OUTPUT_DIR은 실행 사용자 홈 폴더입니다. CSV 파일은 nx_mass_audit_날짜_시간.csv 형식으로 생성합니다. 동일 파일에 덮어쓰지 않으며 UTF-8 BOM으로 저장해 엑셀 한글 깨짐을 줄였습니다. 다만 폴더 쓰기 권한은 확인해야 합니다.
3. 전체 코드 보기 및 복사
NX Open Python 전체 Journal 코드 펼치기
# -*- coding: utf-8 -*-
"""NX Open Python Journal - assembly occurrence mass audit (read-only design).
Test target: NX 2206/2212/2306+ API surfaces; NOT run in NX in this environment.
Does not create expressions/features, modify bodies, assign materials, or save parts.
Reports measured NX physical-material mass (kg), volume (mm^3), derived density
(kg/m^3) only for solids with an explicitly assigned physical material.
Run within NX: Tools > Journal > Play (UI labels may differ by release).
Output: $HOME/nx_mass_audit_YYYYMMDD_HHMMSS.csv
IMPORTANT: trial on a backup/non-confidential assembly first.
"""
import csv
import datetime
import math
import os
import NXOpen
ACCURACY = 0.99
OUTPUT_DIR = os.path.expanduser("~")
FIELDS = [
"component_path", "part_number", "part_units", "occurrence_count",
"body_count", "valid_body_count", "material_names", "volume_mm3",
"density_kg_m3", "mass_kg", "partial_mass_kg", "status", "reason"
]
def safe_text(value):
"""Prevent CSV formula execution when opened in spreadsheet programs."""
s = str(value if value is not None else "").replace("\x00", "")
if s and s[0] in ("=", "+", "-", "@", "\t", "\r", "\n"):
return "'" + s
return s
def default_row(path, part_name="", unit_name=""):
return dict(component_path=path, part_number=part_name,
part_units=unit_name, occurrence_count="1", body_count="0",
valid_body_count="0", material_names="", volume_mm3="",
density_kg_m3="", mass_kg="", partial_mass_kg="",
status="", reason="")
def number(value):
return "{:.9g}".format(value) if value is not None and math.isfinite(value) else ""
def get_units(part):
uc = part.UnitCollection
return [uc.FindObject(name) for name in (
"SquareMilliMeter", "CubicMilliMeter", "Kilogram", "MilliMeter", "Newton")]
def get_part_units(part):
try:
return str(part.PartUnits)
except Exception:
return "unknown (measured output: mm3/kg)"
def read_bodies(part):
return [body for body in part.Bodies if body.IsSolidBody]
def read_physical_material(part, body):
"""No fallback density: if unsupported/unassigned, report explicitly."""
try:
mat = part.MaterialManager.PhysicalMaterials.AskMaterialOfObject(body)
except AttributeError as exc:
return None, "MATERIAL_API_UNAVAILABLE: " + str(exc)
except Exception as exc:
return None, "MATERIAL_QUERY_FAILED: " + str(exc)
if mat is None:
return None, "MISSING_MATERIAL"
try:
return str(mat.Name), None
except Exception:
return "assigned_material", None
def measure_one(part, body, units):
"""Temporary measurement object; no CreateFeature and no saved part changes."""
measurement = None
try:
measurement = part.MeasureManager.NewMassProperties(units, ACCURACY, [body])
return float(measurement.Volume), float(measurement.Mass)
finally:
if measurement is not None:
measurement.Dispose()
def audit_part(part, path):
row = default_row(path, safe_text(part.Name), get_part_units(part))
try:
bodies = read_bodies(part)
row["body_count"] = str(len(bodies))
if not bodies:
row["status"] = "NO_SOLID_BODY"
row["reason"] = "No solid bodies in prototype part"
return row
units = get_units(part)
except Exception as exc:
row["status"] = "PART_READ_FAILED"
row["reason"] = safe_text(str(exc))
return row
volumes, masses, materials, errors = [], [], [], []
for i, body in enumerate(bodies):
name, problem = read_physical_material(part, body)
if problem:
errors.append("body{}:{}".format(i + 1, problem))
continue
try:
vol, mass = measure_one(part, body, units)
if not (math.isfinite(vol) and math.isfinite(mass) and vol > 0 and mass >= 0):
raise ValueError("Invalid volume or mass")
volumes.append(vol)
masses.append(mass)
materials.append(name)
except Exception as exc:
errors.append("body{}:MEASURE_FAILED:{}".format(i + 1, str(exc)))
row["valid_body_count"] = str(len(volumes))
row["material_names"] = safe_text("; ".join(sorted(set(materials))))
if volumes:
row["partial_mass_kg"] = number(sum(masses))
if errors:
row["status"] = "INCOMPLETE"
row["reason"] = safe_text(" | ".join(errors))
return row # no total mass/density for a partially measured part
volume_sum, mass_sum = sum(volumes), sum(masses)
row["volume_mm3"] = number(volume_sum)
row["mass_kg"] = number(mass_sum)
# effective density is mass-weighted at the occurrence level, NOT a material card value.
row["density_kg_m3"] = number(mass_sum / (volume_sum * 1e-9))
row["status"] = "OK_MIXED_MATERIAL" if len(set(materials)) > 1 else "OK"
row["reason"] = "effective density for multiple materials" if len(set(materials)) > 1 else ""
return row
def walk_component(comp, path, rows, cache, visited=None):
# Each occurrence is recorded, but prototype properties can be cached.
name = safe_text(getattr(comp, "DisplayName", getattr(comp, "Name", "component")))
here = path + "/" + name if path else name
try:
if comp.IsSuppressed:
row = default_row(here)
row.update(status="SUPPRESSED", reason="Excluded by current arrangement")
rows.append(row)
return
except Exception as exc:
row = default_row(here)
row.update(status="SUPPRESSION_UNKNOWN", reason=safe_text(str(exc)))
rows.append(row)
return
try:
children = list(comp.GetChildren())
except Exception as exc:
row = default_row(here)
row.update(status="CHILD_QUERY_FAILED", reason=safe_text(str(exc)))
rows.append(row)
return
try:
proto = comp.Prototype
part = proto.OwningPart
if part is None:
raise ValueError("Prototype owning part is not loaded")
key = int(part.Tag)
if key not in cache:
cache[key] = audit_part(part, "")
report = dict(cache[key])
report["component_path"] = here
# Skip empty subassembly container but continue into its children.
if not (children and report["status"] == "NO_SOLID_BODY"):
rows.append(report)
except Exception as exc:
row = default_row(here)
row.update(status="UNLOADED_COMPONENT", reason=safe_text(str(exc)))
rows.append(row)
for child in children:
walk_component(child, here, rows, cache)
def main():
session = NXOpen.Session.GetSession()
lw = session.ListingWindow
lw.Open()
work = session.Parts.Work
if work is None:
lw.WriteLine("[ERROR] Open a part or an assembly first.")
return
rows = []
root = work.ComponentAssembly.RootComponent
if root is None or not list(root.GetChildren()):
rows.append(audit_part(work, safe_text(work.Name)))
else:
cache = {}
for child in root.GetChildren():
walk_component(child, safe_text(work.Name), rows, cache)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
target = os.path.join(OUTPUT_DIR, "nx_mass_audit_" + timestamp + ".csv")
with open(target, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, FIELDS, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
passed = [r for r in rows if r["status"] in ("OK", "OK_MIXED_MATERIAL")]
lw.WriteLine("[DONE] CSV written: " + target)
lw.WriteLine("[COUNT] rows={}, fully-measured={}".format(len(rows), len(passed)))
lw.WriteLine("[INFO] All numerical mass results are NX-measured, not independently verified.")
lw.WriteLine("[INFO] Failed/suppressed/unloaded rows excluded from total.")
lw.WriteLine("[TOTAL] kg={:.9g}".format(sum(float(r["mass_kg"]) for r in passed)))
if __name__ == "__main__":
main()
이 Journal에서 하지 않는 작업
재질을 임의로 지정하거나 빈 값에 강철 밀도를 채우지 않습니다.
부품을 억제 해제하거나 언로드된 형상을 자동으로 로드하지 않습니다.
측정값을 모델 속성이나 표현식에 기록하거나 PRT 파일을 저장하지 않습니다.
영구 Measure Feature를 생성하지 않으며 임시 측정 개체는 Dispose() 처리합니다.
CSV 결과는 정상값과 오류값을 분리해서 확인
다음은 샘플 형상에 가정한 물리 재질이 정확하게 지정되고 NX 측정이 성공했다는 조건부 예상 결과입니다. 이 CSV는 실제 NX 실행 캡처나 실측 자료가 아닙니다.
component_path
mass_kg
density_kg_m3
status
ASSEMBLY/A_AL_01
0.0162
2700
OK
ASSEMBLY/A_AL_02
0.0162
2700
OK
ASSEMBLY/B_STEEL_01
0.0078
7800
OK
ASSEMBLY/C_NO_MATERIAL_01
(빈칸)
(빈칸)
INCOMPLETE
정상행 3개와 재질 누락행 1개를 구분한 뒤 정상 질량만 합하면 0.0402 kg입니다. 실제 검증은 Journal이 생성한 CSV의 질량, 상태, 바디 개수와 이 예상표가 일치하는지 비교하는 방식으로 진행합니다.
혼합 물리 재질의 밀도는 어떻게 표시되나요?
부품의 여러 솔리드 바디가 각기 다른 물리 재질을 가진 경우 질량은 각 바디 측정값을 합산하고, CSV의 밀도는 전체 질량 ÷ 전체 부피로 계산한 유효 밀도를 기록합니다.
이때 상태는 OK_MIXED_MATERIAL로 표시합니다. 해당 값은 특정 한 재질의 원재료 밀도가 아닙니다.
ADVERTISEMENT
오류 코드별 조치 방법
status
발생 원인
먼저 확인할 작업
INCOMPLETE
일부 바디 재질 미지정·측정 실패
reason 칸의 body 번호 확인, 물리 재질 지정 확인
NO_SOLID_BODY
스케치·서피스만 있거나 빈 Part
솔리드 바디 유무 확인
UNLOADED_COMPONENT
프로토타입 Part 접근 실패
어셈블리 로딩 상태 확인 후 재실행
SUPPRESSED
활성 Arrangement에서 억제
BOM 포함 정책을 정하고 별도 관리
MATERIAL_API_UNAVAILABLE
NX 릴리스에서 API 호출 불가
설치 버전의 API 문서로 대응 코드 확인
PART_READ_FAILED
바디 또는 단위 객체 접근 실패
파트 손상·측정 단위·사용 권한 확인
실제 양산 BOM 질량과 비교할 때는 ① 부품 발생 수 ② 재질 누락 ③ 억제 부품 포함 여부 ④ 미로딩 부품 ⑤ 다중 솔리드 재질 지정 여부를 먼저 점검해야 합니다. 단순히 0.0402 kg처럼 합계가 맞는지만 보는 검사는 충분하지 않습니다.
제품디자인과 3D CAD 설계 실무를 기반으로 IT, 윈도우 오류 해결, CAD 소프트웨어, 디자이너 세금, 프리랜서·개인사업자 실무 정보를 정리하고 있습니다. 단순한 이론 설명보다 실제 업무와 블로그 운영, 프로그램 사용 경험을 바탕으로 초보자도 이해하기 쉽게 문제 원인과 해결 방법을 풀어내는 것을 목표로 합니다.