ifemm 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ifemm/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # pylint: skip-file
2
+ # ifemm/__init__.py
3
+
4
+ from ifemm.parser import Parser
5
+
6
+ # Parser import
7
+ FEMMParser = Parser
8
+
9
+ # API Promises
10
+ __all__ = ["Parser",]
ifemm/constants.py ADDED
@@ -0,0 +1,62 @@
1
+ """
2
+ Filename: constants.py
3
+
4
+ Description:
5
+ This file contains constants regarding
6
+ to the parsing of .ans files.
7
+ """
8
+
9
+ # Validate file types for the parser
10
+ FILE_TYPES = [
11
+ ".ans"
12
+ ]
13
+
14
+ # Validate File Formats
15
+ FILE_FORMATS = [
16
+ 4.0
17
+ ]
18
+
19
+ # Validate Problem Parameters
20
+ PROBLEM_PARAMETERS = [
21
+ "format",
22
+ "frequency",
23
+ "precision",
24
+ "minangle",
25
+ "depth",
26
+ "lengthunits",
27
+ "coordinates",
28
+ "problemtype",
29
+ "comment"
30
+ ]
31
+
32
+ # Validate Block Section Names
33
+ BLOCK_SECTIONS = [
34
+ "pointprops",
35
+ "bdryprops",
36
+ "blockprops",
37
+ "circuitprops"
38
+ ]
39
+
40
+ # Validate Block Pairs
41
+ BLOCK_PAIRS = {
42
+ "<beginpoint>": "<endpoint>",
43
+ "<beginbdry>": "<endbdry>",
44
+ "<beginblock>": "<endblock>",
45
+ "<begincircuit>": "<endcircuit>"
46
+ }
47
+
48
+ # Validate Data Section Names
49
+ DATA_SECTIONS = [
50
+ "numblocklabels",
51
+ "numarcsegments",
52
+ "conductorprops",
53
+ "numpoints",
54
+ "numsegments",
55
+ "numholes",
56
+ "<bhpoints>",
57
+ ]
58
+
59
+ # Validate Solution Section Name
60
+ SOLUTION_SECTION = [
61
+ "solution"
62
+ ]
ifemm/core/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # pylint: skip-file
2
+ # FEMMInterpreter/parser/__init__.py
@@ -0,0 +1,92 @@
1
+ """
2
+ Filename: deserialization.py
3
+
4
+ Description:
5
+ Type conversion for .ans format values.
6
+
7
+ Converts strings to Python primitives:
8
+ (int, float, complex, bool, null/none, str).
9
+ """
10
+
11
+
12
+ from __future__ import annotations
13
+ from typing import Any
14
+
15
+ from ifemm.utilities.errors import FailedCasting
16
+
17
+
18
+ class Deserialize:
19
+ """ Deserialization from text to primitives """
20
+ @classmethod
21
+ def cast(cls, text: str) -> int | float | complex | bool | None | str:
22
+ """ Converts text to python primitives """
23
+ if not isinstance(text, str):
24
+ err = f"Expected str, got {type(text).__name__}"
25
+ raise FailedCasting(text, err)
26
+
27
+ text = text.strip()
28
+ if cls.is_quoted(text):
29
+ # Handles quoted strings first
30
+ return str(cls.strip_quotes(text))
31
+
32
+ # Try integer value
33
+ try: return int(text)
34
+ except ValueError: pass
35
+
36
+ # Try float
37
+ try: return float(text)
38
+ except ValueError: pass
39
+
40
+ # Try complex
41
+ try: return complex(text)
42
+ except ValueError: pass
43
+
44
+ # Check boolean
45
+ lower = text.lower()
46
+ if lower == "true": return True
47
+ if lower == "false": return False
48
+
49
+ # Check null/None
50
+ if text.lower() in ("null", "none"): return None
51
+
52
+ # Default to string
53
+ return str(text)
54
+
55
+ @classmethod
56
+ def cast_list(cls, items: list[str]) -> list[Any]:
57
+ """ Casts values within list into primitives """
58
+ casted_list = []
59
+
60
+ for item in items:
61
+ # Casts each item as a python primitive
62
+ casted_list.append(cls.cast(item))
63
+
64
+ return casted_list
65
+
66
+ @classmethod
67
+ def is_quoted(cls, text: str) -> bool:
68
+ """ Check if text is surrounded by quotes """
69
+ text = text.strip()
70
+ if len(text) < 2:
71
+ # Empty quoted text Ex. ("")
72
+ return False
73
+
74
+ if text.startswith('"') and text.endswith('"'):
75
+ # Start and end with double quotes
76
+ return True
77
+
78
+ if text.startswith("'") and text.endswith("'"):
79
+ # Start and end with single quotes
80
+ return True
81
+
82
+ return False
83
+
84
+ @classmethod
85
+ def strip_quotes(cls, text: str) -> str:
86
+ """ Removes surrounding quotes if present """
87
+ text = text.strip()
88
+ if cls.is_quoted(text):
89
+ # Removes double quotation ref. "{Text}"
90
+ return text[1:-1]
91
+
92
+ return text
ifemm/core/states.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ Filename: state.py
3
+
4
+ Description:
5
+ This file contains the parser state
6
+ allowing for access across modules
7
+ without circular implements.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class ParserState:
15
+ """ Stores the state of the parser """
16
+ index: int = 0
17
+ section: str | None = None
18
+ block: str | None = None
19
+ content: dict | None = None
ifemm/core/syntax.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ Filename: syntax.py
3
+
4
+ Description:
5
+ Syntaxes for .ans format.
6
+
7
+ Handles block section and data
8
+ section parsing.
9
+ """
10
+
11
+ from ifemm.core.states import ParserState
12
+ from ifemm.utilities.errors import ParserError
13
+ from ifemm.core.deserialization import Deserialize
14
+
15
+ from ifemm.constants import BLOCK_PAIRS, DATA_SECTIONS
16
+
17
+
18
+ class BlockExtraction:
19
+ """ Extract arbitrary block section data from .ans format """
20
+ @classmethod
21
+ def extract(
22
+ cls, lines: list[str], items: int, state: ParserState
23
+ ) -> tuple[dict, ParserState]:
24
+ """ Extracts the block section """
25
+ if items == 0:
26
+ # If the entry has zero data. Returns None
27
+ return {}, state
28
+
29
+ block = {}
30
+ num_item = 0
31
+
32
+ # Initializes the first item
33
+ block[num_item] = {}
34
+ while state.index < len(lines):
35
+ line = lines[state.index].strip()
36
+ state.index += 1
37
+
38
+ if cls._is_new_blocks(line):
39
+ state.block = line.strip().lower()
40
+ continue
41
+
42
+ if state.block and cls._is_close_block(line, state):
43
+ state.block = None
44
+ num_item += 1
45
+
46
+ # Returns the result after iteration across items
47
+ if num_item == items: return block, state
48
+
49
+ # Adds the entry for the next item
50
+ block[num_item] = {}
51
+ continue
52
+
53
+ if state.block:
54
+ # Extracts block and cases the values within the block
55
+ name, raw_value = cls._extract_block_value(line, state)
56
+ value = Deserialize.cast(raw_value)
57
+
58
+ if name in DATA_SECTIONS:
59
+ # Parses the data section syntaxes
60
+ data, state = DataExtraction.extract(lines, value, state)
61
+ block[num_item][name] = data
62
+ continue
63
+
64
+ # Adds the name and value to the block and section
65
+ block[num_item][name] = value
66
+ continue
67
+
68
+ msg = f"Failed to parse block section, block: {block}, items: {items}"
69
+ raise ParserError(cls.__name__, msg)
70
+
71
+ @classmethod
72
+ def _is_new_blocks(cls, line: str) -> bool:
73
+ """ Checks if its a block section """
74
+ if line.lower() in BLOCK_PAIRS:
75
+ return True
76
+
77
+ return False
78
+
79
+ @classmethod
80
+ def _is_close_block(cls, line: str, state: ParserState) -> bool:
81
+ if state.block is None:
82
+ return False
83
+
84
+ if line.lower() in BLOCK_PAIRS[state.block]:
85
+ return True
86
+
87
+ return False
88
+
89
+ @classmethod
90
+ def _extract_block_value(cls, line: str, state: ParserState) -> tuple[str, str]:
91
+ """ Extract block name and value from line """
92
+ equal_sign = line.find("=")
93
+ if equal_sign == -1:
94
+ msg = f"Malformed block section found in {line!r}, Index: {state.index}"
95
+ raise ParserError(cls.__name__, msg)
96
+
97
+ # Returns stripped lower name and stripped value
98
+ return line[:equal_sign-1].strip().lower(), line[equal_sign+1:].strip()
99
+
100
+
101
+ class DataExtraction:
102
+ """ Extract arbitrary data section from .ans format """
103
+ @classmethod
104
+ def extract(
105
+ cls, lines: list[str], items: int, state: ParserState
106
+ ) -> tuple[dict, ParserState]:
107
+ """ Extracts the data section """
108
+ if items == 0:
109
+ # If the entry has zero data. Returns None
110
+ return {}, state
111
+
112
+ data = {}
113
+ num_item = 0
114
+ while state.index < len(lines):
115
+ line = lines[state.index].strip()
116
+ state.index += 1
117
+
118
+ # Splits the line into values
119
+ values = line.split()
120
+ data[num_item] = Deserialize.cast_list(values)
121
+
122
+ num_item += 1
123
+
124
+ # Returns the result after iteration across items
125
+ if num_item == items: return data, state
126
+
127
+ msg = "Failed to parse data section"
128
+ raise ParserError(cls.__name__, msg)
129
+
130
+
131
+ class SolutionExtraction:
132
+ """ Extract arbitrary solution data from .ans format"""
133
+ @classmethod
134
+ def extract(cls, lines: list[str], state: ParserState) -> tuple[dict, ParserState]:
135
+ """ Extracts the solution data """
136
+ data = {}
137
+ rows = []
138
+ current_item = None
139
+
140
+ while state.index < len(lines):
141
+ line = lines[state.index].strip()
142
+ state.index += 1
143
+
144
+ # Splits the line into values
145
+ raw_values = line.split()
146
+ cast_values = Deserialize.cast_list(raw_values)
147
+
148
+ if len(raw_values) == 1:
149
+ # Updates the section name based off items
150
+ if current_item is not None and rows:
151
+ data[current_item] = cls._transpose(rows)
152
+ rows = []
153
+
154
+ current_item = cast_values[0]
155
+ continue
156
+
157
+ # Skip orphaned data
158
+ if current_item is None: continue
159
+
160
+ rows.append(cast_values)
161
+
162
+ if current_item is not None and rows:
163
+ data[current_item] = cls._transpose(rows)
164
+
165
+ return data, state
166
+
167
+ @classmethod
168
+ def _transpose(cls, rows: list[list]) -> list[list]:
169
+ """ Transpose rows into columns """
170
+ # Skips transposing and returns an empty array
171
+ if not rows: return []
172
+
173
+ # Creates a list of columns to transpose.
174
+ num_cols = len(rows[0])
175
+ transposed = [[] for _ in range(num_cols)]
176
+
177
+ for row in rows:
178
+ for col_idx, value in enumerate(row):
179
+ transposed[col_idx].append(value)
180
+
181
+ return transposed
@@ -0,0 +1,2 @@
1
+ # pylint: skip-file
2
+ # FEMMInterpreter/interpreter/__init__.py
@@ -0,0 +1,33 @@
1
+ """
2
+ Filename: attributes.py
3
+
4
+ Descriptions:
5
+ Defines the `Attribute` structure
6
+ for the `.ans` files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ifemm.constants import FILE_FORMATS
12
+ from ifemm.utilities.errors import FormatNotSupported, AttributeLoadingFailed
13
+ from ifemm.interpreter.magnetic.schema import MagneticData
14
+
15
+
16
+ class AttributeLoader:
17
+ """ Attribute loader for `.ans` files """
18
+ @classmethod
19
+ def load(cls, data: dict, file_type: str) -> MagneticData:
20
+ """ Loads data into type specific structure """
21
+
22
+ file_format = data["format"]
23
+ if file_format not in FILE_FORMATS:
24
+ # File format not supported currently by the interpreter
25
+ raise FormatNotSupported(file_format)
26
+
27
+ match file_type:
28
+ case ".ans":
29
+ # Returns the magnetic data as attribute class
30
+ return MagneticData(data)
31
+
32
+ case _:
33
+ raise AttributeLoadingFailed(file_type)
@@ -0,0 +1,2 @@
1
+ # pylint: skip-file
2
+ # FEMMInterpreter/interpreter/magnetic/__init__.py
@@ -0,0 +1,127 @@
1
+ """
2
+ Filename: definitions.py
3
+
4
+ Description:
5
+ Definitions for magnetic solutions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from typing import Any
10
+
11
+ from abc import ABC
12
+ from dataclasses import dataclass, fields
13
+
14
+
15
+ @dataclass(slots=True, repr=False)
16
+ class Definition(ABC):
17
+ """ Abstract definition class """
18
+ name: str
19
+
20
+ def __repr__(self):
21
+ """ Returns the loaders direct members """
22
+ attributes = [field.name for field in fields(self)]
23
+ items = ', '.join(attributes)
24
+ if self.name is None:
25
+ return f'Definition({items})'
26
+ return f'{self.name}({items})'
27
+
28
+
29
+ @dataclass(slots=True, repr=False)
30
+ class MaterialDefinition(Definition):
31
+ """ Defines a magnetic material """
32
+ name: str
33
+ permeability: tuple[float, float]
34
+ coercive_force: float
35
+ coercive_angle: float
36
+ current_density: tuple[float, float]
37
+ conductivity: float
38
+ lamination_thickness: float
39
+ hysteresis_angle: float
40
+ hysteresis_angle_x: float
41
+ hysteresis_angle_y: float
42
+ lamination_type: int
43
+ lamination_fill: float
44
+ num_strands: int
45
+ wire_diameter: float
46
+ bh_curve: list[tuple[float, float]]
47
+
48
+ @classmethod
49
+ def define(cls, entry: dict[str, Any]) -> MaterialDefinition:
50
+ """ Self-constructs the magnetic material from entry """
51
+ bh_data = entry.get("<bhpoints>", {})
52
+ bh_curve = [(v[0], v[1]) for v in bh_data.values()] if bh_data else []
53
+
54
+ return cls(
55
+ entry["<blockname>"],
56
+ (entry["<mu_x>"], entry["<mu_y>"]),
57
+ entry["<h_c>"],
58
+ entry["<h_cangle>"],
59
+ (entry["<j_re>"], entry["<j_im>"]),
60
+ entry["<sigma>"],
61
+ entry["<d_lam>"],
62
+ entry["<phi_h>"],
63
+ entry["<phi_hx>"],
64
+ entry["<phi_hy>"],
65
+ entry["<lamtype>"],
66
+ entry["<lamfill>"],
67
+ entry["<nstrands>"],
68
+ entry["<wired>"],
69
+ bh_curve
70
+ )
71
+
72
+
73
+ @dataclass(slots=True, repr=False)
74
+ class BoundaryDefinition(Definition):
75
+ """ Defines a boundary condition """
76
+ name: str
77
+ boundary_type: int
78
+ prescribed_a: float
79
+ prescribed_a_x: float
80
+ prescribed_a_y: float
81
+ phase_angle: float
82
+ mixed_c0: float
83
+ mixed_c0_imag: float
84
+ mixed_c1: float
85
+ mixed_c1_imag: float
86
+ mu_ssd: float
87
+ sigma_ssd: float
88
+ inner_angle: float
89
+ outer_angle: float
90
+
91
+ @classmethod
92
+ def define(cls, entry: dict[str, Any]) -> BoundaryDefinition:
93
+ """ Self-constructs the boundary condition from entry """
94
+ return cls(
95
+ entry["<bdryname>"],
96
+ entry["<bdrytype>"],
97
+ entry["<a_0>"],
98
+ entry["<a_1>"],
99
+ entry["<a_2>"],
100
+ entry["<phi>"],
101
+ entry["<c0>"],
102
+ entry["<c0i>"],
103
+ entry["<c1>"],
104
+ entry["<c1i>"],
105
+ entry["<mu_ssd>"],
106
+ entry["<sigma_ssd>"],
107
+ entry["<innerangle>"],
108
+ entry["<outerangle>"]
109
+ )
110
+
111
+ @dataclass(slots=True, repr=False)
112
+ class CircuitDefinition(Definition):
113
+ """ Defines a circuit """
114
+ name: str
115
+ total_amps_real: float
116
+ total_amps_imag: float
117
+ circuit_type: int
118
+
119
+ @classmethod
120
+ def define(cls, entry: dict[str, Any]) -> CircuitDefinition:
121
+ """ Self-constructs the circuit from entry """
122
+ return cls(
123
+ entry["<circuitname>"],
124
+ entry["<totalamps_re>"],
125
+ entry["<totalamps_im>"],
126
+ entry["<circuittype>"]
127
+ )
@@ -0,0 +1,180 @@
1
+ """
2
+ Filename: schema.py
3
+
4
+ Description:
5
+ Magnetic attribute structure
6
+ for FEMM magnetostatics and
7
+ AC simulations.
8
+ """
9
+
10
+ from math import pi
11
+ from typing import Any
12
+ from scipy.interpolate import NearestNDInterpolator, griddata
13
+
14
+ from numpy import (
15
+ column_stack as np_column_stack,
16
+ linspace as np_linspace,
17
+ meshgrid as np_meshgrid,
18
+ array as np_array,
19
+ gradient as np_gradient,
20
+ )
21
+
22
+ from ifemm.interpreter.magnetic.definitions import (
23
+ MaterialDefinition,
24
+ BoundaryDefinition,
25
+ CircuitDefinition
26
+ )
27
+
28
+
29
+ _LENGTH_TO_SI = {
30
+ "meters": 1.0,
31
+ "centimeters": 1e-2,
32
+ "millimeters": 1e-3,
33
+ "inches": 2.54e-2,
34
+ "mils": 2.54e-5,
35
+ }
36
+
37
+
38
+ class MagneticData:
39
+ """ Magnetic Attribute Data """
40
+ def __init__(self, data: dict) -> None:
41
+ """ Initializes the class and loads data into attributes """
42
+ self.data = data
43
+
44
+ # Loads variables into attributes
45
+ self._load_top_level()
46
+ self._load_boundaries()
47
+ self._load_materials()
48
+ self._load_circuits()
49
+
50
+ # Creates the A potential map
51
+ self._constructs_potential_map()
52
+
53
+ @property
54
+ def length_scale(self) -> float:
55
+ """ Conversion factor from model length units to metres. """
56
+ try:
57
+ return _LENGTH_TO_SI[self.length_unit.lower()]
58
+
59
+ except KeyError:
60
+ msg = f"Unsupported FEMM length unit: {self.length_unit}"
61
+ raise ValueError(msg) from None
62
+
63
+ def _constructs_potential_map(self, eps: float = 1e-6) -> None:
64
+ """ Constructs the vector potential map """
65
+ solution = next(iter(self.data["solution"]))
66
+
67
+ # Convert to three lists for each dimension
68
+ self.vector_x = self.data["solution"][solution][0]
69
+ self.vector_y = self.data["solution"][solution][1]
70
+ self.vector_a = self.data["solution"][solution][2]
71
+
72
+ # Converts flux to a-potential for axisymmetric solutions
73
+ if self.problem_type == "axisymmetric":
74
+ converted = []
75
+ for r, a in zip(self.vector_x, self.vector_a):
76
+ if r > eps:
77
+ converted.append(a / (2.0 * pi * r * self.length_scale))
78
+ else:
79
+ converted.append(0.0)
80
+ self.vector_a = converted
81
+
82
+ # Convert to numpy arrays for interpolation
83
+ points = np_column_stack((self.vector_x, self.vector_y))
84
+ values = np_array(self.vector_a)
85
+
86
+ # Create the interpolation function
87
+ self._interpolator = NearestNDInterpolator(points, values)
88
+
89
+ def point_potential(self, x: float, y: float) -> float:
90
+ """ Return the magnetic vector potential A at point (x, y). """
91
+ result = self._interpolator(x, y)
92
+
93
+ # NearestNDInterpolator always returns a value
94
+ return result
95
+
96
+ def field_potential(self, resolution: int = 1000) -> tuple[Any, Any, Any]:
97
+ """ Returns the interpolated potential field. """
98
+ x_min, x_max = min(self.vector_x), max(self.vector_x)
99
+ y_min, y_max = min(self.vector_y), max(self.vector_y)
100
+
101
+ # Creates a x and y space
102
+ xi = np_linspace(x_min, x_max, resolution)
103
+ yi = np_linspace(y_min, y_max, resolution)
104
+ x_space, y_space = np_meshgrid(xi, yi)
105
+
106
+ # Interpolate A onto x, y space
107
+ a_grid = griddata(
108
+ (self.vector_x, self.vector_y),
109
+ self.vector_a,
110
+ (x_space, y_space),
111
+ method='linear'
112
+ )
113
+
114
+ # Returns the X, Y and A spaces
115
+ return x_space, y_space, a_grid
116
+
117
+ def b_field(self, resolution: int = 1000) -> tuple[Any, Any, Any, Any]:
118
+ """ Returns the interpolated B field (Bx, By) from the vector potential A. """
119
+ x_space, y_space, a_grid = self.field_potential(resolution)
120
+
121
+ scale = self.length_scale
122
+
123
+ # Compute gradients in SI metres
124
+ dx = (x_space[0, 1] - x_space[0, 0]) * scale
125
+ dy = (y_space[1, 0] - y_space[0, 0]) * scale
126
+
127
+ da_dx = np_gradient(a_grid, dx, axis=1)
128
+ da_dy = np_gradient(a_grid, dy, axis=0)
129
+
130
+ # B = curl(A)
131
+ bx = da_dy
132
+ by = -da_dx
133
+
134
+ return x_space, y_space, bx, by
135
+
136
+ def _load_circuits(self) -> None:
137
+ """ Loads materials section from the solution """
138
+ circuits = self.data["circuitprops"]
139
+
140
+ for key in circuits:
141
+ # Sets the circuit definition as attribute
142
+ circuit = CircuitDefinition.define(circuits[key])
143
+ setattr(self, circuit.name, circuit)
144
+
145
+ def _load_materials(self) -> None:
146
+ """ Loads materials section from the solution """
147
+ materials = self.data["blockprops"]
148
+
149
+ for key in materials:
150
+ # Sets the material definition as attribute
151
+ material = MaterialDefinition.define(materials[key])
152
+ setattr(self, material.name, material)
153
+
154
+ def _load_boundaries(self) -> None:
155
+ """ Loads boundaries section from the solution """
156
+ boundaries = self.data["bdryprops"]
157
+
158
+ for key in boundaries:
159
+ # Sets the boundary definition as attribute
160
+ boundary = BoundaryDefinition.define(boundaries[key])
161
+ setattr(self, boundary.name, boundary)
162
+
163
+ def _load_top_level(self) -> None:
164
+ """ Loads the top level sections from the solution """
165
+ # File & Version
166
+ self.format_version: float = self.data["format"]
167
+
168
+ # Problem Definition
169
+ self.frequency_hz: float = self.data["frequency"]
170
+ self.solver_precision: float = self.data["precision"]
171
+ self.min_angle_deg: float = self.data["minangle"]
172
+
173
+ # Mesh Settings
174
+ self.model_depth: float = self.data["depth"]
175
+ self.length_unit: str = self.data["lengthunits"]
176
+ self.problem_type: str = self.data["problemtype"]
177
+ self.coordinate_system: str = self.data["coordinates"]
178
+
179
+ # Metadata
180
+ self.comment_text: str = self.data["comment"]
ifemm/parser.py ADDED
@@ -0,0 +1,126 @@
1
+ """
2
+ Filename: main.py
3
+
4
+ Description:
5
+ Domain specific language parser for .ans file
6
+
7
+ Orchestrates parsing the files and producing
8
+ field representations for layer.
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ from ifemm.core.states import ParserState
14
+ from ifemm.core.deserialization import Deserialize
15
+ from ifemm.core.syntax import BlockExtraction, DataExtraction, SolutionExtraction
16
+ from ifemm.constants import FILE_TYPES, BLOCK_SECTIONS, DATA_SECTIONS, SOLUTION_SECTION
17
+
18
+ from ifemm.utilities.errors import ParserError, FileTypeNotSupported
19
+ from ifemm.interpreter.attributes import AttributeLoader, MagneticData
20
+
21
+
22
+ class Parser:
23
+ """ Parser for .ans file format. """
24
+ @classmethod
25
+ def open(cls, filepath: Path | str ) -> MagneticData:
26
+ """ Parses .ans file into a field representation. """
27
+ if not isinstance(filepath, (str, Path)):
28
+ # Raises error for non supported path type
29
+ msg = f"Invalid path type {type(filepath)!r} for {cls.__name__!r}"
30
+ raise TypeError(msg)
31
+
32
+ # Constructs a path and extracts file type
33
+ path = Path(filepath)
34
+ file_type = path.suffix.lower()
35
+
36
+ # Raises error for non supported file type
37
+ if file_type not in FILE_TYPES: raise FileTypeNotSupported(file_type)
38
+ # Raises error for non supported file type
39
+
40
+ lines = cls._read_lines(filepath)
41
+ data = ParseLines.parse(lines)
42
+ return AttributeLoader.load(data, file_type)
43
+
44
+ @staticmethod
45
+ def _read_lines(filepath_or_file: Path | str) -> list[str]:
46
+ """ Read lines from file path or file-like object. """
47
+
48
+ # Convert to Path and validate
49
+ filepath = Path(filepath_or_file)
50
+ if not filepath.exists():
51
+ raise FileNotFoundError(f"File not found: {filepath}")
52
+
53
+ with filepath.open('r', encoding='utf-8') as f:
54
+ return f.readlines()
55
+
56
+
57
+ class ParseLines:
58
+ """ Parses lines for .ans file format. """
59
+ @classmethod
60
+ def parse(cls, lines: list[str]) -> dict:
61
+ """ Parses and extracts logic from raw text into structured results. """
62
+ state = ParserState()
63
+ state.content = {}
64
+
65
+ while state.index < len(lines):
66
+ line = lines[state.index].strip()
67
+ state.index += 1
68
+
69
+ if not line:
70
+ # Skips empty lines
71
+ continue
72
+
73
+ # Extracts the solution and value than casts it in primitives
74
+ is_section, section = cls._is_section(line)
75
+ is_value, raw_value = cls._extract_section_value(line)
76
+
77
+ value = Deserialize.cast(raw_value)
78
+
79
+ if section in BLOCK_SECTIONS:
80
+ # Parses block section syntaxes
81
+ data, state = BlockExtraction.extract(lines, value, state)
82
+ state.content[section] = data
83
+ continue
84
+
85
+ if section in DATA_SECTIONS:
86
+ # Parses the data section syntaxes
87
+ data, state = DataExtraction.extract(lines, value, state)
88
+ state.content[section] = data
89
+ continue
90
+
91
+ if section in SOLUTION_SECTION:
92
+ # Parse the solution section syntaxes
93
+ data, state = SolutionExtraction.extract(lines, state)
94
+ state.content[section] = data
95
+ continue
96
+
97
+ if is_section and is_value:
98
+ # Adds the section value under section name
99
+ state.content[section] = value
100
+ continue
101
+
102
+ return state.content
103
+ return state.content
104
+
105
+ @classmethod
106
+ def _is_section(cls, line: str) -> tuple[bool, str]:
107
+ """ Check if line defines a section [name]. """
108
+ line = line.strip()
109
+ if not line.startswith('['):
110
+ return False, ""
111
+
112
+ closing_bracket = line.find(']')
113
+ if closing_bracket == -1:
114
+ msg = "closing bracket not found in-line"
115
+ raise ParserError(cls.__name__, msg)
116
+
117
+ return True, line[1:closing_bracket].lower()
118
+
119
+ @classmethod
120
+ def _extract_section_value(cls, line: str) -> tuple[bool, str]:
121
+ """ Extract section or subsection values. """
122
+ equal_sign = line.find("=")
123
+ if equal_sign == -1:
124
+ return False, ""
125
+
126
+ return True, line[equal_sign+1:].strip()
@@ -0,0 +1,2 @@
1
+ # pylint: skip-file
2
+ # FEMMInterpreter/utilities/__init__.py
@@ -0,0 +1,62 @@
1
+ """
2
+ Filename: parser_errors.py
3
+
4
+ Description:
5
+ Defines the errors classes to ensure
6
+ descriptive error messages.
7
+ """
8
+
9
+ from typing import Any
10
+
11
+ from ifemm.constants import FILE_FORMATS, FILE_TYPES
12
+
13
+ # pylint: disable=line-too-long
14
+
15
+ # Generic Errors
16
+ class ParserError(ValueError):
17
+ """ Exception for Parser errors when parsing """
18
+ def __init__(self, caller: str, error: str):
19
+ """ Returns a custom error message """
20
+ msg = f"[] '{caller}' raised error: {error}. "
21
+ super().__init__(msg)
22
+
23
+
24
+ # Specific errors
25
+ class FailedCasting(ValueError):
26
+ """ Exception for failure during casting """
27
+ CODE = "E001"
28
+
29
+ def __init__(self, text: Any, error: str):
30
+ """ Returns a failed casting error """
31
+ msg = f"[{self.CODE}] Failed to cast {text!r} as Python primitive, error: {error!r}"
32
+ super().__init__(msg)
33
+
34
+
35
+ class FileTypeNotSupported(Exception):
36
+ """ Exception for file type not supported """
37
+ CODE = "E002"
38
+
39
+ def __init__(self, file_type: str):
40
+ """ Returns a file type not supported error """
41
+ msg = f"[{self.CODE}] Failed to parse {file_type!r} as not in supported list {FILE_TYPES!r}"
42
+ super().__init__(msg)
43
+
44
+
45
+ class FormatNotSupported(Exception):
46
+ """ Exception for format not supported """
47
+ CODE = "E003"
48
+
49
+ def __init__(self, file_format: str):
50
+ """ Returns a format not supported error """
51
+ msg = f"[{self.CODE}] Failed to parse {file_format!r} as not in supported list {FILE_FORMATS!r}"
52
+ super().__init__(msg)
53
+
54
+
55
+ class AttributeLoadingFailed(Exception):
56
+ """ Exception for attribute loading failing """
57
+ CODE = "E004"
58
+
59
+ def __init__(self, file_type: str):
60
+ """ Returns a loading failure error """
61
+ msg = f"[{self.CODE}] Failed to load {file_type!r} attributes into attribute structure"
62
+ super().__init__(msg)
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: ifemm
3
+ Version: 0.2.0
4
+ Summary: A python library for interpreting FEMM solution files
5
+ Project-URL: Homepage, https://github.com/wgbowley/ifemm
6
+ Project-URL: Bug Tracker, https://github.com/wgbowley/ifemm/issues
7
+ Author-email: William Bowley <wgrantbowley@gmail.com>
8
+ Maintainer-email: William Bowley <wgrantbowley@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 William Bowley
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: FEM,FEMM,Parsing,format
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Scientific/Engineering
37
+ Requires-Python: >=3.10
38
+ Requires-Dist: numpy>=1.24.0
39
+ Requires-Dist: scipy>=1.10.0
40
+ Description-Content-Type: text/markdown
41
+
42
+ <!--
43
+ Color Palette:
44
+ #FFFFFF - pure white
45
+ #00FFFF - pure, highly saturated shade of Cyan
46
+
47
+ It's a simple piece of kit, but being able to integrate
48
+ FEMM solutions directly into the pipeline is extremely
49
+ useful.
50
+
51
+ - William Bowley, 2026-08-17
52
+
53
+ P.S: Thanks for downloading the ifemm repository `▽`ʃ♡
54
+ -->
55
+
56
+ ### Overview
57
+
58
+ ![Version](https://img.shields.io/badge/Version-0.2.0-FFFFFF?style=flat-square)
59
+ ![License](https://img.shields.io/badge/License-MIT-00FFFF?style=flat-square&color=00FFFF)
60
+ ![Python Version](https://img.shields.io/badge/Python-3.10%2B-FFFFFF?style=flat-square)
61
+ [![PyPI Downloads](https://img.shields.io/pepy/dt/femminterpreter?label=downloads\&style=flat-square\&color=00FFFF)](https://pepy.tech/projects/femminterpreter)
62
+
63
+ A Python library for interpreting Finite Element Method Magnetic (FEMM) files and exposing FEMM solution data
64
+ through a Python attribute-based interface. `ifemm` exposes the `A-field` as `A(x,y)`, independent of planar or axisymmetric coordinate systems.
65
+
66
+ #### Proposed Integration
67
+
68
+ ```
69
+ FEMM Setup & Solve → FEMM (.ans) → ifemm → Reduced Order Models / Analytical Models
70
+ ```
71
+
72
+ ---
73
+
74
+ ### Example
75
+
76
+ An example of a dipole being plotted can be found in [/examples](examples/) with `.ans` and `.py` files.
77
+
78
+ <div align="center">
79
+ <img
80
+ src="https://raw.githubusercontent.com/wgbowley/FEMMInterpreter/refs/heads/main/media/planar_llc_b_field.png"
81
+ alt="B-field of transformer"
82
+ style="max-width: 600px"
83
+ >
84
+ <p>
85
+ <em> B-field of a planar LLC transformer from FEMM (.ans) </em>
86
+ </p>
87
+ </div>
88
+
89
+ ---
90
+
91
+ ### Quick Start
92
+
93
+ ```py
94
+ from ifemm import Parser
95
+
96
+ # Imports the parser and parses the .ans file
97
+ PATH = "examples/magnetostatic.ans"
98
+ data = Parser.open(PATH)
99
+
100
+ # Result as a float with implicit unit of wb/length_unit
101
+ a_potential = data.point_potential(0, 0)
102
+ ```
103
+
104
+ ---
105
+
106
+ ### Installation
107
+
108
+ To install,
109
+
110
+ ```
111
+ pip install ifemm
112
+ ```
113
+
114
+ ---
115
+
116
+ ### Documentation
117
+
118
+ Full documentation is available in the [`docs/`](https://github.com/wgbowley/ifemm/tree/main/docs) folder, including API reference, changelog, and contributors.
119
+
120
+ ---
@@ -0,0 +1,18 @@
1
+ ifemm/__init__.py,sha256=Kggl2PH-Ua9TIBk73jLUI-4VrMG5JzAQcUAfSoRbUHA,147
2
+ ifemm/constants.py,sha256=6452Yild1ElbwkJN9CJM0zNrfZfF7szEaYqf8JVh0QA,1009
3
+ ifemm/parser.py,sha256=arg3s1XrFc3dud5crcOeTJF9aFm9w7RYfnj07QRPl6Q,4297
4
+ ifemm/core/__init__.py,sha256=d9hDTekERYUfOF1O0Ur9blpDDx2_w34VQT6p0KDDryA,56
5
+ ifemm/core/deserialization.py,sha256=TEKWAH-SktCI_0a7wMnQQdKVn1vwCTVOrLvLjHOs51k,2454
6
+ ifemm/core/states.py,sha256=lA033cCIj1aVEepIU5RPlavl8ii8YR2_ZD63c7RvZKg,374
7
+ ifemm/core/syntax.py,sha256=agtoF1km1oqPzuIaJfJmkWzDnEypGBVMzKGeiLi5W8w,5593
8
+ ifemm/interpreter/__init__.py,sha256=rH5wLOJMxWUN1WaCQY8hINVBi4pzqmtGLBV3PMx3bR0,62
9
+ ifemm/interpreter/attributes.py,sha256=KKR0oJ2FV3DTpnc0pM1Yus6JIrPELW5ATi0_XT21Myk,968
10
+ ifemm/interpreter/magnetic/__init__.py,sha256=72nvrj9WhEkcmKyV2rOR0Th3qK5_dg8wTXZ49mnQw6g,71
11
+ ifemm/interpreter/magnetic/definitions.py,sha256=iDPgml7A7YT9-fZGgj8Oa0962yxuFoHFSojknPm7wd8,3479
12
+ ifemm/interpreter/magnetic/schema.py,sha256=tOh7oFu0vqSWdQ1KotFCmLi7hrgK-EI8X7QA5xI1gYQ,5882
13
+ ifemm/utilities/__init__.py,sha256=df7bmOOqt3lTii-yT4Z5ywRaf7RSjg2RSESm3DNuFEk,59
14
+ ifemm/utilities/errors.py,sha256=WHSQS6qVqEjL5EKVQRGw7YRD4wsZRCm0vUNVtIkWaA0,1855
15
+ ifemm-0.2.0.dist-info/METADATA,sha256=bd0rgi2oT62lO94haArd_9haHQ52CPrJr3RfijsPzOQ,4066
16
+ ifemm-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ ifemm-0.2.0.dist-info/licenses/LICENSE,sha256=PB4SZBAK73vxATTUsk5y_0XiWa0lHuZREvj3y4Syr04,1071
18
+ ifemm-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Bowley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.