บทเรียน 2.1 — สุ่มสัญญาณให้ตรงกับโมเดล: อัตราสุ่ม Nyquist หน้าต่าง และ schema ของ CSV

สุ่มสัญญาณเซนเซอร์แล้วเก็บเป็นไฟล์ของเราเอง

โมดูล 2 — เก็บข้อมูลจากเซนเซอร์ (DAQ)

Pillar 1 — Data Acquisition (เริ่มไล่วงจรจากต้นน้ำ)

คาถาประจำบทเรียน: "โมเดลทุกตัวเกิดจากข้อมูลที่มีคนเก็บมาก่อน — วันนี้เราเป็นคนเก็บเอง แล้วจะเห็นว่า dataset ไม่ใช่ของวิเศษ มันคือไฟล์ CSV ที่เราเขียนทีละบรรทัด"

MicroPython บนบอร์ด BENTO (PSoC Edge · Cortex-M55 + Ethos-U55 NPU)

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

เปิดบทเรียนด้วยของจริงก่อน

เหมือนทุกบทเรียน เราเริ่มแบบ กลับด้าน — รันของที่ทำงานได้จริงก่อน แล้วค่อยแกะว่ามันทำงานยังไง

รัน logger ก่อน เก็บท่า -> CSV แกะดูข้างใน rate · schema · file เติม/แก้เอง 4 จังหวะหลัก เอาไป train บทเรียน 5.1–5.2+

วันนี้ "ของเจ๋ง" ไม่ใช่โมเดลที่รู้จำท่าทาง แต่เป็น ไฟล์ CSV ไฟล์แรกของเราเอง ที่เต็มไปด้วยตัวเลขจากเซนเซอร์จริง เปิดหน้าจอ กดปุ่ม shaking เขย่าบอร์ด แล้วดูตัวเลข samples วิ่งขึ้น — นั่นคือ dataset กำลังก่อตัวอยู่ตรงหน้า

ชุดบทเรียนนี้ยังไม่ต้อง train อะไรทั้งนั้น ขอแค่ได้เห็นว่า "ข้อมูลที่โมเดลกิน" หน้าตาเป็นยังไง และมันมาจากมือเราเองได้ยังไง เท่านั้นพอ

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

เป้าหมายของชุดบทเรียนนี้

จบชุดบทเรียนนี้เราจะเข้าใจ 4 เรื่องของ DAQ (Data Acquisition) แล้วปิดท้ายด้วยไฟล์ dataset จริง:

  1. DAQ คืออะไร และทำไมข้อมูลต้องมาก่อนโมเดลเสมอ
  2. อัตราสุ่ม (sampling rate) — Hz คืออะไร ทำไมต้องคงที่ และทำไมต้องตรงกับที่โมเดลกิน
  3. schema ของ CSV — หนึ่งบรรทัดต่อหนึ่ง sample: label,ax,ay,az,gx,gy,gz
  4. เขียนลงไฟล์บนบอร์ด — dataset อยู่บน flash ของอุปกรณ์เอง

ปลายทางของวันนี้: กดปุ่ม label ทำท่าค้างไว้ ระบบเก็บ 200 samples/ครั้งลง /gestures.csv — ครบทุกท่าแล้วได้ dataset พร้อม train

เรากำลังเดินถอยจากปลายน้ำ (บทเรียน 1.1–1.3 และ 1.6–1.7 รันโมเดลสำเร็จรูป) มาที่ ต้นน้ำ — ที่ซึ่งข้อมูลเกิดขึ้นจริง นี่คือ Pillar 1 ของทั้งวงจร

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

ชุดบทเรียนนี้อยู่ตรงไหนของวงจร

จำวงจร 5 ขั้นจากบทเรียน 1.1–1.3 ได้ไหม — DAQ → Processing → Analysis → Training → Apps เราเริ่มจากขั้น Apps (ปลายน้ำ) มาสามชุดบทเรียน วันนี้ถอยกลับมาที่ขั้น 1 · DAQ (ต้นน้ำ)

1 · DAQ เก็บข้อมูลดิบ เราอยู่ตรงนี้ 2 · Processing คณิต+ฟิสิกส์ 3 · Analysis DSP · FFT · feature 4 · Training ฝึกโมเดล 5 · Apps 1.1–1.3 · 1.6–1.7 (มาแล้ว) "กลับด้าน" — เห็นปลายน้ำก่อน (Apps) แล้วถอยมาต้นน้ำ (DAQ) บทเรียน 2.1–2.2–2.3–2.4 = Pillar 1 · จากนี้เราจะไล่ขึ้นทีละขั้นจนฝึกโมเดลของตัวเองได้

ทำไมถอยมาต้นน้ำตอนนี้? เพราะจะเข้าใจว่าโมเดล "เห็นอะไร" ต้องเริ่มจากการเป็นคนป้อนข้อมูลให้มันเองก่อน — ชุดบทเรียนนี้คือก้าวแรกของการเป็นเจ้าของ dataset

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

DAQ คืออะไร — ทำไมข้อมูลมาก่อนโมเดล

DAQ (Data Acquisition) คือการ เก็บข้อมูลดิบจากเซนเซอร์ ให้เป็นชุดที่จัดเก็บได้ ค้นได้ เอาไปใช้ต่อได้ — นี่คือวัตถุดิบตั้งต้นของทุกโมเดล

  • โมเดล Motion ที่เราเล่นในบทเรียน 1.1–1.3 (idle / circle / shaking) ไม่ได้เกิดมาลอยๆ มีคน เขย่าเซนเซอร์แล้วเก็บตัวเลขไว้ เป็นพันๆ sample ก่อน แล้วจึงเอาไป train
  • ถ้าไม่มีขั้นนี้ ก็ไม่มีอะไรให้โมเดลเรียนรู้ — DAQ จึงเป็น ขั้นที่ 1 เสมอ ไม่ใช่เรื่องรอง
  • วันนี้เราทำสิ่งเดียวกับที่ทีมสร้างโมเดลจริงทำ: เลือกท่า ตั้งชื่อ (label) แล้วอัดข้อมูลลงไฟล์
เซนเซอร์จริง IMU 6 แกน สุ่มที่อัตราคงที่ 50 ครั้ง/วินาที ติด label "shaking" CSV บนบอร์ด dataset

"DAQ" เป็นคำที่วิศวกรใช้จริงในสายวัดคุม/instrumentation มานานก่อนยุค AI — หัวใจเหมือนเดิม: อ่านสัญญาณให้ถูกจังหวะ แล้วเก็บให้ครบถ้วนตรงเวลา

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

Garbage in, garbage out — คุณภาพของ dataset

มีคำพูดในวงการ ML ที่จริงเสมอ: "ข้อมูลเข้าเป็นขยะ ผลออกก็เป็นขยะ" โมเดลเก่งแค่ไหนก็แพ้ dataset ที่เก็บมั่ว

  • ติด label ถูก — ถ้ากดปุ่ม shaking แต่ดันวางบอร์ดนิ่ง ข้อมูลนั้นจะสอนโมเดลผิด
  • อัตราคงที่ — ถ้าสุ่มเดี๋ยวเร็วเดี๋ยวช้า รูปคลื่นจะบิด โมเดลจะสับสน
  • เก็บพอๆ กันทุกคลาส (class balance) — ถ้าเก็บ idle 1000 แต่ shaking 50 โมเดลจะเอนไปทาย idle
  • สภาพจริงหลากหลาย — เขย่าแรง/เบา เร็ว/ช้า มือคนละคน ยิ่งหลากหลาย โมเดลยิ่งทน

ชุดบทเรียนนี้เราจะลงมือเก็บจริง แล้วคุณจะรู้สึกเองว่า "การเก็บข้อมูลดีๆ ก็เป็นงานฝีมือ" — ไม่ใช่แค่กดปุ่มมั่วๆ นี่คือเหตุผลที่ dataset ดีมีค่า

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

อัตราสุ่ม (sampling rate) คืออะไร

อัตราสุ่ม คือ "จำนวนครั้งที่เราอ่านเซนเซอร์ต่อวินาที" หน่วยเป็น เฮิรตซ์ (Hz) — 50 Hz แปลว่าอ่าน 50 ครั้งใน 1 วินาที คือทุกๆ 20 มิลลิวินาที

สัญญาณจริง (ต่อเนื่อง) 20 ms แต่ละจุดแดง = หนึ่ง sample ที่เราเก็บ (ทุก 20 ms = 50 Hz)
  • เราไม่ได้เก็บ "ทุกช่วงเวลา" แต่เก็บเป็น จุดๆ ที่ระยะเท่าๆ กัน — เอาจุดพวกนี้มาเรียงกันก็เห็นเป็นรูปคลื่น
  • สุ่มถี่ (Hz สูง) = เก็บรายละเอียดได้มาก แต่ไฟล์ใหญ่และกินแรง · สุ่มห่าง (Hz ต่ำ) = ประหยัด แต่พลาดรายละเอียดเร็วๆ

กฎง่ายๆ: ถ้าจะจับสัญญาณที่แกว่งเร็วแค่ไหน ต้องสุ่มให้ถี่กว่านั้นอย่างน้อยเท่าตัว (เดี๋ยวเรื่อง FFT ในโมดูล 4 (Analysis) จะเจาะลึกกฎนี้)

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

ทำไมต้อง 50 Hz — ต้องตรงกับที่โมเดลกิน

ในไฟล์เราตั้ง RATE_MS = 20 ซึ่งคือ 50 Hz ไม่ใช่เลขสุ่ม แต่เลือกให้ ตรงกับอัตราที่โมเดล Motion บนบอร์ดกินจริง

RATE_MS = 20            # 50 Hz — ตรงกับอัตราที่โมเดล Motion บนบอร์ดกิน
BURST   = 200           # 200 sample = ~4 วินาที ที่ 50 Hz
  • โมเดลถูก train มาบนข้อมูล 50 Hz ถ้าเราเก็บที่อัตราอื่น (เช่น 10 Hz) รูปคลื่นจะ "ยืด/หด" ไม่เหมือนที่โมเดลเคยเห็น
  • นี่คือหลักสำคัญของ Edge AI: อัตราสุ่มตอนเก็บข้อมูล ต้องเท่ากับตอนใช้งานจริง ไม่งั้น dataset สวยแค่ไหนก็ใช้ไม่ได้
  • BURST = 200 เลือกให้หนึ่งครั้งที่กดปุ่ม ได้ข้อมูล ~4 วินาที — ยาวพอให้ทำท่าได้เต็มรอบ

จำเลข 20 ms นี้ไว้ให้ดี — ในบทเรียน 1.1–1.3 เราเคยเห็น edge_ai อ่านผลทุก ~180 ms แต่ การป้อนข้อมูลให้โมเดล ต้องถี่กว่านั้นมาก เพราะโมเดลต้องเห็นรูปคลื่นละเอียด

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

คณิตเบื้องหลัง (1) — กฎ Nyquist

ทำไมต้องสุ่ม "เร็วกว่าสองเท่า" ของสัญญาณที่อยากจับ? มีกฎคณิตศาสตร์รองรับ เรียกว่า กฎการสุ่มของ Nyquist–Shannon:

fs  ≥  2 fmaxf_s \;\ge\; 2\,f_{max}

อ่านสัญลักษณ์ทีละตัวแบบง่ายๆ:

  • fsf_s = อัตราสุ่ม (sampling rate) หน่วย Hz — จำนวนครั้งที่เราอ่านเซนเซอร์ต่อวินาที (ในไฟล์เราคือ 50)
  • fmaxf_{max} = ความถี่สูงสุด ในสัญญาณที่เราอยากจับได้ หน่วย Hz — เช่น การเขย่ามือเร็วสุดราว 5–10 Hz
  • ≥2\ge 2 = ต้องสุ่มให้ถี่ อย่างน้อยสองเท่า ของ fmaxf_{max} ไม่งั้นเก็บรูปคลื่นไม่ครบ
คลื่นหนึ่งลูก (หนึ่ง cycle) ต้องมีจุดแดงเก็บอย่างน้อย 2 จุด ถึงจะรู้ว่ามันแกว่ง

ถ้าสุ่มช้ากว่าเกณฑ์นี้ สัญญาณเร็วจะ "แปลงร่าง" กลายเป็นคลื่นช้าปลอมๆ เรียกว่า aliasing — dataset จะโกหกโดยที่เราไม่รู้ตัว นี่คือเหตุผลลึกๆ ที่ชุดบทเรียนนี้ย้ำเรื่องอัตราสุ่มคงที่

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

คณิตเบื้องหลัง (2) — ความยาวหน้าต่าง (window)

รู้อัตราสุ่มแล้ว คำนวณต่อได้เลยว่า หนึ่ง burst ที่เก็บ NN sample กินเวลากี่วินาที:

T  =  NfsT \;=\; \frac{N}{f_s}

  • TT = ความยาวเวลา ของหนึ่ง burst (หนึ่งหน้าต่าง/window) หน่วยวินาที
  • NN = จำนวน sample ที่เก็บต่อหนึ่งครั้ง — ในไฟล์เราคือ BURST = 200
  • fsf_s = อัตราสุ่ม = 50 Hz

แทนค่าจริงของชุดบทเรียนนี้:

T  =  20050  =  4 วินาทีT \;=\; \frac{200}{50} \;=\; 4 \ \text{วินาที}

นี่คือที่มาของ "~4 วินาที" ที่พูดถึงตอนเลือก BURST. ลองสังเกต: ถ้าลด fsf_s เหลือ 25 Hz แต่ยังเก็บ N=200N = 200 เท่าเดิม จะได้ T=200/25=8T = 200/25 = 8 วินาที — นานขึ้นสองเท่า ตรงกับการบ้านข้อ 3 พอดี

ทำไมต้องรู้ TT? เพราะตอน train โมเดลกินข้อมูล ทีละหน้าต่าง (TT วินาที) เราต้องมั่นใจว่าหน้าต่างยาวพอครอบท่าเต็มรอบ — สูตรสั้นๆ นี้บอกได้ทันทีโดยไม่ต้องเดา

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

BURST — เก็บกี่ sample ต่อการกดหนึ่งครั้ง

เราตั้ง BURST = 200 หนึ่งครั้งที่กดปุ่ม จะเก็บ 200 sample ต่อเนื่อง ทำไมต้อง 200 ไม่ใช่ 5 หรือ 5000?

  • ที่ 50 Hz, 200 sample = ~4 วินาที — ยาวพอให้ทำท่าเต็มรอบ (เขย่าไปมาหลายครั้ง, วาดวงกลมจบวง)
  • ท่าทางเป็น รูปแบบตามเวลา ไม่ใช่ค่าจุดเดียว — โมเดลต้องเห็นหลาย sample เรียงกันถึงจะแยก circle ออกจาก shaking ได้
  • น้อยไป (เช่น 5 sample) = ไม่เห็นรูปคลื่นครบ · มากเกินจำเป็น = ไฟล์บวมและเสียเวลาเก็บ
หนึ่ง burst = 200 sample เรียงตามเวลา (~4 วินาที): โมเดลดู "รูปคลื่นทั้งช่วง" ไม่ใช่ค่าเดียว - จึงต้องเก็บเป็นชุดต่อเนื่อง

เดี๋ยวในบทเรียน Analysis (บทเรียน 4.5–4.6) เราจะเจอคำว่า window (หน้าต่าง) — โมเดลจริงกินข้อมูลทีละหน้าต่าง เช่น 1-2 วินาที BURST วันนี้คือการเก็บให้ยาวพอครอบหลายหน้าต่าง

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

schema ของ CSV — หนึ่งบรรทัดต่อหนึ่ง sample

CSV (Comma-Separated Values) คือไฟล์ข้อความธรรมดา แต่ละบรรทัดคือหนึ่งแถว แต่ละค่าคั่นด้วยจุลภาค บรรทัดแรกคือ หัวตาราง (schema) บอกว่าคอลัมน์ไหนคืออะไร

label,ax,ay,az,gx,gy,gz <- schema (บรรทัดแรก) shaking,0.9123,-0.0421,9.7810,12.44,-3.10,0.88 shaking,1.2044,0.3310,9.6620,18.20,-1.55,2.01 shaking,-0.4410,0.1120,9.9032,-5.62,4.71,-1.20 ... (อีก 197 บรรทัดต่อการกดหนึ่งครั้ง) แต่ละบรรทัด = หนึ่ง snapshot ของ IMU 6 แกน + ชื่อท่า
  • คอลัมน์ label = ชื่อท่า (เราเลือกก่อนกด) · ax..az = ความเร่ง 3 แกน · gx..gz = ไจโร 3 แกน
  • ทำไม CSV? เพราะ เปิดได้ทุกที่ — Excel, Python (pandas), เครื่องมือ train ทั้งหมดอ่าน CSV ได้ทันที
  • ทำไมมี label ในทุกบรรทัด? เพราะตอน train เราต้องรู้ว่าแต่ละ sample เป็นท่าอะไร (supervised learning)

schema คือ "สัญญา" ระหว่างคนเก็บข้อมูลกับคนเทรน — ถ้าเรียงคอลัมน์สลับ หรือหัวไม่ตรง เครื่อง train จะอ่านผิดทันที ต้องเป๊ะ

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

เขียนลงไฟล์บนบอร์ด — dataset อยู่บน flash เอง

จุดที่น่าทึ่ง: เราเขียนไฟล์ CSV ลง หน่วยความจำ flash บนบอร์ดเอง ด้วย open() / write() แบบเดียวกับ Python บนคอมเป๊ะ

with open("/gestures.csv", "a") as f:      # "a" = append ต่อท้าย
    f.write("shaking,0.91,-0.04,9.78,12.4,-3.1,0.9\n")
  • "/gestures.csv" — path เริ่มด้วย / คือ root ของ filesystem บนบอร์ด (LittleFS)
  • โหมด "a" (append) = เขียนต่อท้าย ไม่ลบของเก่า — สำคัญมาก ถ้าใช้ "w" ทุกครั้งจะทับ dataset หายหมด
  • with open(...) as f: — เปิดแล้วปิดให้อัตโนมัติ ข้อมูลถูก flush ลง flash เรียบร้อยตอนออกจาก with

ข้อมูลอยู่บนอุปกรณ์ ไม่ได้ขึ้นคลาวด์ — สอดคล้องกับหัวใจ Edge AI ตั้งแต่ชุดบทเรียนแรก: ข้อมูลเกิดที่ไหน ประมวลผล/เก็บที่นั่น เดี๋ยวโมดูล 5 (Training) เราค่อยดึงไฟล์นี้ออกไป train บนคอม

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

sensors.bmi270.motion() — อ่าน 6 แกนใน snapshot เดียว

หัวใจของการ sample คือคำสั่งเดียว มันคืน 6 ค่าพร้อมกัน: ความเร่ง 3 แกน + ไจโร 3 แกน

import sensors
ax, ay, az, gx, gy, gz = sensors.bmi270.motion()
#   |__ accelerometer (m/s^2) __|  |__ gyroscope (dps) __|
  • ax, ay, az — ความเร่งแนวแกน X/Y/Z หน่วย m/s² (วางนิ่งแกนที่ตั้งฉากพื้นจะอ่านได้ ~9.8 จากแรงโน้มถ่วง)
  • gx, gy, gz — ความเร็วเชิงมุม (การหมุน) หน่วยองศา/วินาที
  • ทำไมใช้ motion() ไม่ใช่ acceleration() + gyroscope() แยก? เพราะ motion() อ่านทั้ง 6 แกน ใต้ bus lock เดียว = ทุกแกนเป็นเวลาเดียวกันเป๊ะ (snapshot ตรงเวลา)

เวลาตรงกันของทุกแกนสำคัญมากตอน train — ถ้า ax กับ gx มาจากคนละจังหวะเวลา รูปคลื่นจะเพี้ยน motion() แก้ปัญหานี้ให้เราแล้ว

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0

ถามเซนเซอร์ก่อนเขียนโค้ด — ลองใน REPL

นิสัยที่ดีจากบทเรียน 1.1–1.3: ถามฮาร์ดแวร์ก่อน อย่าเดา ก่อนเขียน logger ลองเรียก motion() ใน REPL ดูว่าได้อะไรกลับมาจริง

>>> import sensors
>>> sensors.bmi270.motion()
(0.0412, -0.0231, 9.7810, 0.44, -0.12, 0.08)   # วางนิ่งบนโต๊ะ
>>> sensors.bmi270.motion()
(1.9044, 0.8830, 8.2210, 45.20, -18.5, 12.3)   # ขณะเขย่า
  • วางนิ่ง: แกนที่ตั้งฉากพื้นอ่านได้ ~9.8 (แรงโน้มถ่วง) ไจโรใกล้ 0 (ไม่หมุน)
  • เขย่า: ทุกค่าแกว่งแรง โดยเฉพาะไจโร (gx,gy,gz) พุ่งขึ้นเพราะมีการหมุน
  • เห็นด้วยตาก่อนว่าค่าจริงหน้าตาแบบนี้ พอเอาไปเขียนลงไฟล์ เราจะรู้ทันทีว่าข้อมูล "สมเหตุผล" ไหม

ลองสองสามครั้งใน REPL ก่อนรัน logger — จะได้ feel ว่าค่านิ่งกับค่าเขย่าต่างกันแค่ไหน นี่คือ "การทำความรู้จักข้อมูล" ที่วิศวกรทำก่อนเก็บจริงเสมอ

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก Edge AI Developer (รศ.วิรุฬห์ ศรีบริรักษ์, BUU) · CC BY-NC 4.0