Coverage for nexusLIMS/schemas/metadata.py: 100%

144 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2026-03-24 05:23 +0000

1""" 

2Type-specific metadata schemas for NexusLIMS extractor plugins. 

3 

4This module defines Pydantic models for validating metadata extracted from different 

5types of microscopy data (Image, Spectrum, SpectrumImage, Diffraction). Each schema 

6uses Pint Quantity objects for physical measurements, EM Glossary field names, and 

7supports flexible extension fields. 

8 

9The schemas follow a hierarchical structure: 

10- :class:`NexusMetadata` - Base schema with common fields 

11- :class:`ImageMetadata` - SEM/TEM/STEM image data 

12- :class:`SpectrumMetadata` - EDS/EELS spectral data 

13- :class:`SpectrumImageMetadata` - Hyperspectral data (inherits from both) 

14- :class:`DiffractionMetadata` - Diffraction pattern data 

15 

16**Key Features:** 

17- Pint Quantity fields for machine-actionable units 

18- EM Glossary v2.0.0 terminology (see :doc:`/dev_guide/em_glossary_reference`) 

19- Automatic unit normalization to preferred units 

20- Flexible extensions section for instrument-specific metadata 

21- Strict validation of core fields 

22 

23Examples 

24-------- 

25Validate SEM image metadata: 

26 

27>>> from nexusLIMS.schemas.metadata import ImageMetadata 

28>>> from nexusLIMS.schemas.units import ureg 

29>>> 

30>>> meta = ImageMetadata( 

31... creation_time="2024-01-15T10:30:00-05:00", 

32... data_type="SEM_Imaging", 

33... dataset_type="Image", 

34... acceleration_voltage=ureg.Quantity(10, "kilovolt"), 

35... working_distance=ureg.Quantity(10, "millimeter"), 

36... beam_current=ureg.Quantity(100, "picoampere"), 

37... ) 

38>>> print(meta.acceleration_voltage) 

3910.0 kilovolt 

40 

41Validate spectrum metadata: 

42 

43>>> from nexusLIMS.schemas.metadata import SpectrumMetadata 

44>>> 

45>>> spec_meta = SpectrumMetadata( 

46... creation_time="2024-01-15T10:30:00-05:00", 

47... data_type="EDS_Spectrum", 

48... dataset_type="Spectrum", 

49... acquisition_time=ureg.Quantity(30, "second"), 

50... live_time=ureg.Quantity(28.5, "second"), 

51... ) 

52 

53Use extensions for instrument-specific fields: 

54 

55>>> meta_with_ext = ImageMetadata( 

56... creation_time="2024-01-15T10:30:00-05:00", 

57... data_type="SEM_Imaging", 

58... dataset_type="Image", 

59... extensions={ 

60... "facility": "Nexus Microscopy Center", 

61... "detector_brightness": 50.0, 

62... "scan_speed": 6, 

63... } 

64... ) 

65 

66For detailed documentation on the metadata schema system, see: 

67 

68- :doc:`/dev_guide/nexuslims_internal_schema` - Schema architecture, 

69 migration guide, and usage examples 

70- :doc:`/dev_guide/em_glossary_reference` - Complete EM Glossary field 

71 reference and mapping tables 

72""" 

73 

74import logging 

75from datetime import datetime 

76from typing import Any, Dict, Literal 

77 

78from pydantic import BaseModel, Field, field_validator, model_validator 

79 

80from nexusLIMS.schemas import em_glossary 

81from nexusLIMS.schemas.pint_types import PintQuantity 

82 

83_logger = logging.getLogger(__name__) 

84 

85 

86def emg_field( 

87 field_name: str, 

88 default: Any = None, 

89 *, 

90 description: str | None = None, 

91 **kwargs: Any, 

92) -> Any: 

93 """ 

94 Create a Pydantic Field with EM Glossary metadata. 

95 

96 This helper automatically adds EM Glossary semantic annotations to field 

97 definitions, including EMG ID, URI, and label. It pulls metadata from the 

98 :mod:`~nexusLIMS.schemas.em_glossary` module to maintain a single source 

99 of truth. 

100 

101 Parameters 

102 ---------- 

103 field_name : str 

104 Internal field name (e.g., "acceleration_voltage"). Used to look up 

105 EMG metadata from :mod:`~nexusLIMS.schemas.em_glossary` module. 

106 

107 default : Any, optional 

108 Default value for the field. Use `...` for required fields, `None` 

109 for optional fields. 

110 

111 description : str, optional 

112 Field description. If not provided, uses description from 

113 :mod:`~nexusLIMS.schemas.em_glossary` module. 

114 

115 **kwargs : Any 

116 Additional keyword arguments passed to :func:`pydantic.fields.Field`, such as: 

117 - `alias`: Override display name (default from em_glossary) 

118 - `gt`, `ge`, `lt`, `le`: Numeric constraints 

119 - `examples`: Example values for documentation 

120 - `json_schema_extra`: Additional JSON schema metadata (merged with EMG data) 

121 

122 Returns 

123 ------- 

124 :class:`pydantic.fields.FieldInfo` 

125 Configured Pydantic field with EMG metadata 

126 

127 Examples 

128 -------- 

129 Create a field with automatic EMG metadata: 

130 

131 >>> from nexusLIMS.schemas.metadata import emg_field 

132 >>> from nexusLIMS.schemas.pint_types import PintQuantity 

133 >>> 

134 >>> class MySchema(BaseModel): 

135 ... dwell_time: PintQuantity | None = emg_field("dwell_time") 

136 

137 The field automatically gets: 

138 - `alias`: "Dwell Time" (display name) 

139 - `description`: "Time period during which the beam remains at one position." 

140 - `json_schema_extra`: `{"emg_id": "EMG_00000015", "emg_uri": "...", ...}` 

141 

142 Override description: 

143 

144 >>> acceleration_voltage: PintQuantity | None = emg_field( 

145 ... "dwell_time", 

146 ... description="Custom description", 

147 ... ) 

148 

149 Add additional JSON schema metadata: 

150 

151 >>> beam_current: PintQuantity | None = emg_field( 

152 ... "beam_current", 

153 ... json_schema_extra={"units": "second", "typical_range": "1e-3 to 1"}, 

154 ... ) 

155 

156 Notes 

157 ----- 

158 - Fields without EMG mappings still get display names and descriptions 

159 - EMG metadata is only added if the field has a valid EMG ID 

160 - The alias (display name) comes from em_glossary for consistency 

161 - All EMG metadata is stored in json_schema_extra for JSON schema export 

162 """ 

163 # Get EMG metadata 

164 emg_id = em_glossary.get_emg_id(field_name) 

165 emg_uri = em_glossary.get_emg_uri(field_name) 

166 display_name = kwargs.pop("alias", None) or em_glossary.get_display_name(field_name) 

167 field_description = description or em_glossary.get_description(field_name) 

168 

169 # Build json_schema_extra with EMG metadata 

170 extra = kwargs.pop("json_schema_extra", {}) 

171 if emg_id: 

172 emg_label = em_glossary.get_emg_label(emg_id) 

173 extra.update( 

174 { 

175 "emg_id": emg_id, 

176 "emg_uri": emg_uri, 

177 "emg_label": emg_label, 

178 } 

179 ) 

180 

181 return Field( 

182 default, 

183 alias=display_name, 

184 description=field_description, 

185 json_schema_extra=extra if extra else None, 

186 **kwargs, 

187 ) 

188 

189 

190class ExtractionDetails(BaseModel): 

191 """ 

192 Metadata about the NexusLIMS extraction process. 

193 

194 Records when metadata was extracted, which extractor module was used, 

195 and the NexusLIMS version. 

196 """ 

197 

198 date: str = Field( 

199 ..., 

200 alias="Date", 

201 description="ISO-8601 timestamp when extraction occurred", 

202 ) 

203 """ISO-8601 formatted timestamp with timezone indicating when the 

204 metadata extraction occurred.""" 

205 

206 module: str = Field( 

207 ..., 

208 alias="Module", 

209 description="Extractor module name", 

210 ) 

211 """Fully qualified Python module name of the extractor that processed 

212 this file. Examples: `'nexusLIMS.extractors.plugins.digital_micrograph'`, 

213 `'nexusLIMS.extractors.plugins.quanta_tif'`""" 

214 

215 version: str = Field( 

216 ..., 

217 alias="Version", 

218 description="NexusLIMS version", 

219 ) 

220 """NexusLIMS version string used for extraction. Example: `'1.2.3'`""" 

221 

222 extractor_warnings: str | None = Field( 

223 None, 

224 alias="Extractor Warnings", 

225 description="Warning or error messages from the extraction process", 

226 ) 

227 """Warning or error messages from the extraction process""" 

228 

229 model_config: dict = { 

230 "populate_by_name": True, 

231 } 

232 """ 

233 Pydantic model configuration: 

234 

235 - ``populate_by_name: True`` -- Accept both Python field names and JSON aliases 

236 """ 

237 

238 

239class StagePosition(BaseModel): 

240 """ 

241 Stage position with coordinates and tilt angles. 

242 

243 Represents the physical position and orientation of the microscope stage. 

244 All fields use Pint Quantity objects with appropriate units and are optional 

245 to accommodate different stage configurations. 

246 

247 Examples 

248 -------- 

249 >>> from nexusLIMS.schemas.metadata import StagePosition 

250 >>> from nexusLIMS.schemas.units import ureg 

251 >>> 

252 >>> pos = StagePosition( 

253 ... x=ureg.Quantity(100, "um"), 

254 ... y=ureg.Quantity(200, "um"), 

255 ... z=ureg.Quantity(5, "mm"), 

256 ... tilt_alpha=ureg.Quantity(10, "degree"), 

257 ... ) 

258 >>> print(pos.x) 

259 100 micrometer 

260 

261 Notes 

262 ----- 

263 Some microscopes may not have all degrees of freedom. Single-tilt stages 

264 will only have tilt_alpha, while dual-tilt stages (e.g., tomography holders) 

265 will have both tilt_alpha and tilt_beta. 

266 """ 

267 

268 x: PintQuantity | None = Field( 

269 None, 

270 description="Stage X coordinate", 

271 ) 

272 """Stage X coordinate. Preferred unit: micrometer (µm)""" 

273 

274 y: PintQuantity | None = Field( 

275 None, 

276 description="Stage Y coordinate", 

277 ) 

278 """Stage Y coordinate. Preferred unit: micrometer (µm)""" 

279 

280 z: PintQuantity | None = Field( 

281 None, 

282 description="Stage Z coordinate (height)", 

283 ) 

284 """Stage Z coordinate (height). Preferred unit: millimeter (mm)""" 

285 

286 rotation: PintQuantity | None = Field( 

287 None, 

288 description="Stage rotation angle around Z axis", 

289 ) 

290 """Stage rotation angle around Z axis. Preferred unit: degree (°)""" 

291 

292 tilt_alpha: PintQuantity | None = Field( 

293 None, 

294 description="Tilt angle along primary tilt axis (alpha)", 

295 ) 

296 """ 

297 Tilt angle along the stage's primary tilt axis (alpha). 

298 Preferred unit: degree (°) 

299 """ 

300 

301 tilt_beta: PintQuantity | None = Field( 

302 None, 

303 description="Tilt angle along secondary tilt axis (beta), if capable", 

304 ) 

305 """ 

306 Tilt angle along the stage's secondary tilt axis (beta), if the stage 

307 is capable of dual-axis tilting. Preferred unit: degree (°) 

308 """ 

309 

310 model_config = { 

311 "extra": "allow", # Allow additional vendor-specific coordinates 

312 } 

313 """ 

314 Pydantic model configuration: 

315 

316 - ``extra: "allow"`` -- Allow additional vendor-specific stage positions 

317 """ 

318 

319 

320class NexusMetadata(BaseModel): 

321 """ 

322 Base schema for all NexusLIMS metadata. 

323 

324 This is the foundation schema that all type-specific schemas inherit from. 

325 It defines the required fields common to all dataset types and provides 

326 the extension mechanism for instrument-specific metadata. 

327 

328 Notes 

329 ----- 

330 The extensions section allows arbitrary metadata while maintaining strict 

331 validation on core fields. This hybrid approach ensures: 

332 

333 - Core fields are consistent and validated 

334 - Instrument-specific metadata is preserved 

335 - No data loss during extraction 

336 

337 Extensions should use descriptive key names and avoid conflicts with core 

338 field names. 

339 """ 

340 

341 # Required fields (common to all types) 

342 creation_time: str = Field( 

343 ..., 

344 alias="Creation Time", 

345 description="ISO-8601 timestamp with timezone", 

346 ) 

347 """ 

348 ISO-8601 formatted timestamp with timezone indicating when the data 

349 was acquired. Must include timezone offset (+00:00, -05:00) or 'Z'. 

350 Examples: "2024-01-15T10:30:00-05:00", "2024-01-15T15:30:00Z" 

351 """ 

352 

353 data_type: str = Field( 

354 ..., 

355 alias="Data Type", 

356 description="Human-readable data type description", 

357 ) 

358 """ 

359 Human-readable description of the data type using underscore-separated 

360 components. Examples: "STEM_Imaging", "TEM_EDS", "SEM_Imaging" 

361 """ 

362 

363 dataset_type: Literal[ 

364 "Image", 

365 "Spectrum", 

366 "SpectrumImage", 

367 "Diffraction", 

368 "Misc", 

369 "Unknown", 

370 ] = Field( 

371 ..., 

372 alias="DatasetType", 

373 description="Schema-defined dataset category", 

374 ) 

375 """ 

376 Schema-defined category matching the Nexus Experiment XML schema type 

377 attribute. 

378 """ 

379 

380 # Common optional fields 

381 data_dimensions: str | None = Field( 

382 None, 

383 alias="Data Dimensions", 

384 description="String representation of data shape", 

385 ) 

386 """ 

387 String representation of data shape as a tuple. 

388 Examples: "(1024, 1024)", "(2048,)", "(12, 1024, 1024)" 

389 """ 

390 

391 instrument_id: str | None = Field( 

392 None, 

393 alias="Instrument ID", 

394 description="NexusLIMS persistent instrument identifier", 

395 ) 

396 """ 

397 NexusLIMS persistent identifier for the instrument. 

398 Examples: "FEI-Titan-TEM-635816", "Quanta-FEG-650-SEM-555555" 

399 """ 

400 

401 warnings: list[str | list[str]] = Field( 

402 default_factory=list, 

403 description="Field names flagged as unreliable", 

404 ) 

405 """ 

406 Field names flagged as unreliable. These are marked with warning="true" 

407 in the XML output. 

408 """ 

409 

410 nexuslims_extraction: ExtractionDetails | None = Field( 

411 None, 

412 alias="NexusLIMS Extraction", 

413 description="NexusLIMS extraction metadata (date, module, version, warnings)", 

414 ) 

415 """ 

416 NexusLIMS extraction metadata containing date, module, and version 

417 information about when and how the metadata was extracted. 

418 """ 

419 

420 extensions: Dict[str, Any] = Field( 

421 default_factory=dict, 

422 description="Instrument-specific metadata extensions", 

423 ) 

424 """ 

425 Flexible container for instrument-specific metadata that doesn't fit 

426 the core schema. Use this for vendor-specific fields, facility metadata, 

427 or experimental parameters not covered by EM Glossary. 

428 """ 

429 

430 # Configuration 

431 model_config = { 

432 "populate_by_name": True, # Accept both Python names and aliases 

433 "extra": "forbid", # Force use of extensions for extra fields 

434 } 

435 """ 

436 Pydantic model configuration: 

437 

438 - ``populate_by_name: True`` -- Accept both Python field names and JSON aliases 

439 - ``extra: "forbid"`` -- Forbid extra fields (forces use of extensions dict 

440 for additional data) 

441 """ 

442 

443 @field_validator("creation_time") 

444 @classmethod 

445 def validate_iso_timestamp(cls, v: str) -> str: 

446 """Validate ISO-8601 timestamp with timezone.""" 

447 try: 

448 datetime.fromisoformat(v) 

449 except ValueError as e: 

450 msg = f"Invalid ISO-8601 timestamp format: {v}" 

451 raise ValueError(msg) from e 

452 

453 # Require timezone information 

454 min_dashes_for_tz = 3 

455 if not ("+" in v or v.endswith("Z") or v.count("-") >= min_dashes_for_tz): 

456 msg = ( 

457 f"Timestamp must include timezone: {v}. " 

458 f"Use format like '2024-01-15T10:30:00-05:00' or '...Z'" 

459 ) 

460 raise ValueError(msg) 

461 

462 return v 

463 

464 @field_validator("data_type") 

465 @classmethod 

466 def validate_data_type_not_empty(cls, v: str) -> str: 

467 """Validate ``data_type`` is not empty.""" 

468 if not v or not v.strip(): 

469 msg = "Data Type cannot be empty" 

470 raise ValueError(msg) 

471 return v 

472 

473 

474class ImageMetadata(NexusMetadata): 

475 """ 

476 Schema for image dataset metadata (SEM, TEM, STEM, FIB, HIM). 

477 

478 Extends :class:`NexusMetadata` with fields specific to 2D image acquisition. 

479 Uses Pint Quantity objects for all physical measurements. 

480 

481 Examples 

482 -------- 

483 >>> from nexusLIMS.schemas.metadata import ImageMetadata 

484 >>> from nexusLIMS.schemas.units import ureg 

485 >>> 

486 >>> meta = ImageMetadata( 

487 ... creation_time="2024-01-15T10:30:00-05:00", 

488 ... data_type="SEM_Imaging", 

489 ... dataset_type="Image", 

490 ... acceleration_voltage=ureg.Quantity(15, "kV"), 

491 ... working_distance=ureg.Quantity(10.5, "mm"), 

492 ... beam_current=ureg.Quantity(50, "pA"), 

493 ... magnification=5000.0, 

494 ... ) 

495 """ 

496 

497 dataset_type: Literal["Image"] = Field( 

498 "Image", 

499 alias="DatasetType", 

500 description="Must be 'Image' for this schema", 

501 ) 

502 

503 # Image-specific fields (using EM Glossary names) 

504 acceleration_voltage: PintQuantity | None = emg_field("acceleration_voltage") 

505 """ 

506 Accelerating voltage of the electron/ion beam. 

507 Preferred unit: kilovolt (kV). EM Glossary: EMG_00000004 

508 """ 

509 

510 working_distance: PintQuantity | None = emg_field("working_distance") 

511 """ 

512 Distance between final lens and sample surface. 

513 Preferred unit: millimeter (mm). EM Glossary: EMG_00000050 

514 """ 

515 

516 beam_current: PintQuantity | None = emg_field("beam_current") 

517 """ 

518 Electron beam current. 

519 Preferred unit: picoampere (pA). EM Glossary: EMG_00000006 

520 """ 

521 

522 emission_current: PintQuantity | None = emg_field("emission_current") 

523 """ 

524 Emission current from electron source. 

525 Preferred unit: microampere (µA). EM Glossary: EMG_00000025 

526 """ 

527 

528 dwell_time: PintQuantity | None = emg_field("dwell_time") 

529 """ 

530 Time the beam dwells on each pixel during scanning. 

531 Preferred unit: microsecond (µs). EM Glossary: EMG_00000015 

532 """ 

533 

534 magnification: float | None = emg_field("magnification") 

535 """Nominal magnification (dimensionless).""" 

536 

537 horizontal_field_width: PintQuantity | None = emg_field("horizontal_field_width") 

538 """Width of the scanned area. Preferred unit: micrometer (µm)""" 

539 

540 vertical_field_width: PintQuantity | None = emg_field("vertical_field_width") 

541 """Height of the scanned area. Preferred unit: micrometer (µm)""" 

542 

543 pixel_width: PintQuantity | None = emg_field("pixel_width") 

544 """Physical width of a single pixel. Preferred unit: nanometer (nm)""" 

545 

546 pixel_height: PintQuantity | None = emg_field("pixel_height") 

547 """Physical height of a single pixel. Preferred unit: nanometer (nm)""" 

548 

549 scan_rotation: PintQuantity | None = emg_field("scan_rotation") 

550 """Rotation angle of the scan frame. Preferred unit: degree (°)""" 

551 

552 detector_type: str | None = emg_field("detector_type") 

553 """Type or name of detector used. Examples: "ETD", "InLens", "HAADF", "BF" """ 

554 

555 acquisition_device: str | None = emg_field("acquisition_device") 

556 """ 

557 Name of the acquisition device or camera. 

558 Examples: "BM-UltraScan", "K2 Summit" 

559 """ 

560 

561 stage_position: StagePosition | None = Field( 

562 None, 

563 description="Stage coordinates and tilt angles", 

564 ) 

565 """ 

566 Stage coordinates and tilt angles. See :class:`StagePosition` for details. 

567 Preferred units: x/y in µm, z in mm, angles in degrees 

568 """ 

569 

570 

571class SpectrumMetadata(NexusMetadata): 

572 """ 

573 Schema for spectrum dataset metadata (EDS, EELS, etc.). 

574 

575 Extends :class:`NexusMetadata` with fields specific to spectral data acquisition. 

576 

577 Examples 

578 -------- 

579 >>> from nexusLIMS.schemas.metadata import SpectrumMetadata 

580 >>> from nexusLIMS.schemas.units import ureg 

581 >>> 

582 >>> meta = SpectrumMetadata( 

583 ... creation_time="2024-01-15T10:30:00-05:00", 

584 ... data_type="EDS_Spectrum", 

585 ... dataset_type="Spectrum", 

586 ... acquisition_time=ureg.Quantity(30, "s"), 

587 ... live_time=ureg.Quantity(28.5, "s"), 

588 ... channel_size=ureg.Quantity(10, "eV"), 

589 ... ) 

590 """ 

591 

592 dataset_type: Literal["Spectrum"] = Field( 

593 "Spectrum", 

594 alias="DatasetType", 

595 description="Must be 'Spectrum' for this schema", 

596 ) 

597 

598 # Spectrum-specific fields 

599 acquisition_time: PintQuantity | None = emg_field("acquisition_time") 

600 """ 

601 Total time for spectrum acquisition. 

602 Preferred unit: second (s). EM Glossary: EMG_00000055 

603 """ 

604 

605 live_time: PintQuantity | None = emg_field("live_time") 

606 """Live time excluding dead time. Preferred unit: second (s)""" 

607 

608 detector_energy_resolution: PintQuantity | None = emg_field( 

609 "detector_energy_resolution" 

610 ) 

611 """Energy resolution of the detector. Preferred unit: electronvolt (eV)""" 

612 

613 channel_size: PintQuantity | None = emg_field("channel_size") 

614 """Energy width of each channel. Preferred unit: electronvolt (eV)""" 

615 

616 starting_energy: PintQuantity | None = emg_field("starting_energy") 

617 """Starting energy of the spectrum. Preferred unit: kiloelectronvolt (keV)""" 

618 

619 azimuthal_angle: PintQuantity | None = emg_field("azimuthal_angle") 

620 """Azimuthal angle of the detector. Preferred unit: degree (°)""" 

621 

622 elevation_angle: PintQuantity | None = emg_field("elevation_angle") 

623 """Elevation angle of the detector. Preferred unit: degree (°)""" 

624 

625 takeoff_angle: PintQuantity | None = emg_field("takeoff_angle") 

626 """X-ray takeoff angle. Preferred unit: degree (°)""" 

627 

628 elements: list[str] | None = Field( 

629 None, 

630 description="Detected elements", 

631 ) 

632 """Detected elements (e.g., ["Fe", "Cr", "Ni"])""" 

633 

634 

635class SpectrumImageMetadata(ImageMetadata, SpectrumMetadata): 

636 """ 

637 Schema for spectrum image (hyperspectral) dataset metadata. 

638 

639 Combines fields from both :class:`ImageMetadata` and :class:`SpectrumMetadata` 

640 since spectrum images have both spatial and spectral dimensions. Inherits all 

641 fields from both parent classes. 

642 

643 Examples 

644 -------- 

645 >>> from nexusLIMS.schemas.metadata import SpectrumImageMetadata 

646 >>> from nexusLIMS.schemas.units import ureg 

647 >>> 

648 >>> meta = SpectrumImageMetadata( 

649 ... creation_time="2024-01-15T10:30:00-05:00", 

650 ... data_type="STEM_EDS_SpectrumImage", 

651 ... dataset_type="SpectrumImage", 

652 ... acceleration_voltage=ureg.Quantity(200, "kV"), # Image field 

653 ... acquisition_time=ureg.Quantity(1200, "s"), # Spectrum field 

654 ... pixel_time=ureg.Quantity(0.5, "s"), # SpectrumImage specific 

655 ... ) 

656 """ 

657 

658 dataset_type: Literal["SpectrumImage"] = Field( 

659 "SpectrumImage", 

660 alias="DatasetType", 

661 description="Must be 'SpectrumImage' for this schema", 

662 ) 

663 

664 # SpectrumImage-specific fields 

665 pixel_time: PintQuantity | None = Field( 

666 None, 

667 description="Time per pixel for spectrum acquisition", 

668 ) 

669 """Time spent acquiring spectrum at each pixel. Preferred unit: second (s)""" 

670 

671 scan_mode: str | None = Field( 

672 None, 

673 description="Scanning mode (raster, serpentine, etc.)", 

674 ) 

675 """ 

676 Scanning mode used for acquisition. 

677 Examples: "raster", "serpentine", "fly-back" 

678 """ 

679 

680 @model_validator(mode="after") 

681 def validate_spectrum_image_fields(self) -> "SpectrumImageMetadata": 

682 """Ensure SpectrumImage has both image and spectrum metadata.""" 

683 # Just a placeholder - could add validation logic here if needed 

684 return self 

685 

686 

687class DiffractionMetadata(NexusMetadata): 

688 """ 

689 Schema for diffraction pattern dataset metadata (TEM, EBSD, etc.). 

690 

691 Extends :class:`NexusMetadata` with fields specific to diffraction data. 

692 

693 Examples 

694 -------- 

695 >>> from nexusLIMS.schemas.metadata import DiffractionMetadata 

696 >>> from nexusLIMS.schemas.units import ureg 

697 >>> 

698 >>> meta = DiffractionMetadata( 

699 ... creation_time="2024-01-15T10:30:00-05:00", 

700 ... data_type="TEM_Diffraction", 

701 ... dataset_type="Diffraction", 

702 ... camera_length=ureg.Quantity(200, "mm"), 

703 ... convergence_angle=ureg.Quantity(0.5, "mrad"), 

704 ... acceleration_voltage=ureg.Quantity(200, "kV"), 

705 ... ) 

706 """ 

707 

708 dataset_type: Literal["Diffraction"] = Field( 

709 "Diffraction", 

710 alias="DatasetType", 

711 description="Must be 'Diffraction' for this schema", 

712 ) 

713 

714 # Diffraction-specific fields 

715 camera_length: PintQuantity | None = emg_field("camera_length") 

716 """ 

717 Camera length for diffraction pattern. 

718 Preferred unit: millimeter (mm). EM Glossary: EMG_00000008 

719 """ 

720 

721 convergence_angle: PintQuantity | None = emg_field("convergence_angle") 

722 """ 

723 Convergence angle of the electron beam. 

724 Preferred unit: milliradian (mrad). EM Glossary: EMG_00000010 

725 """ 

726 

727 acceleration_voltage: PintQuantity | None = emg_field("acceleration_voltage") 

728 """ 

729 Accelerating voltage (also relevant for diffraction). 

730 Preferred unit: kilovolt (kV). EM Glossary: EMG_00000004 

731 """ 

732 

733 acquisition_device: str | None = emg_field("acquisition_device") 

734 """Name of the detector/camera used."""