49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314 | class Controller:
"""
Orchestrates a node's duty cycle based on the capacitor's voltage:
listening -> sensing -> processing -> transmitting -> (loop back to listening)
Goes idle (off, charging) when voltage drops below low threshold.
Expects:
- capacitor_ctrl: has .action, .current (A), .voltage_source (V), .voltage (V),
.internal_time (s), and responds to interrupts:
'capacitor_discharge', 'capacitor_charging', 'capacitor_full'
- backscatter: has .state, .controller_tx_ready(sensor_data), .do_transmit(tx_info)
"""
def __init__(
self,
env: simpy.Environment,
capacitor_ctrl,
node,
backscatter,
params: ControllerParams,
coverage_map=None,
downlink_results=None,
keep_logs: bool = True, # if False, skip per-step diagnostic logging (flat memory)
# dB the strongest reader must beat the other readers by before this
# tag's envelope detector can still read its query
dl_capture_db: float = 10.0,
):
self.env = env
self.id = node.id
self.node = node
self.capacitor_ctrl = capacitor_ctrl
self.backscatter = backscatter
self.coverage_map = coverage_map
self.downlink_results = downlink_results
self.p = params
self.keep_logs = keep_logs
capacitor_ctrl.params = replace(capacitor_ctrl.params,
dt=params.tick_ms / 1000.0)
# FSM state
self.state_name = "listening"
self.is_active = False
# data path
self.sensed_value = 0
self.processed_data = 0
self.data_history: List[Tuple[float, int]] = []
# counters
self.counter = 0 # generic step counter for states
self.last_current = 0.0
self.pending_tx_info = None # stored tx info from backscatter
# state start times (ms)
self.listening_start_ms = 0.0
self.sensing_start_ms = 0.0
self.processing_start_ms = 0.0
self.transmitting_start_ms = 0.0
# startup delay (s)
self.startup_delay_s = (
random.randrange(0, max(1, self.p.max_startup_time_ms)) / 1000.0
)
# current logging buckets (for optional analytics)
self.acc_current_listening: List[float] = []
self.acc_current_sensing: List[float] = []
self.acc_current_processing: List[float] = []
self.acc_current_transmitting: List[float] = []
self.fsm_count_in_current_cap_cycle = 0
self.fsm_counts_per_cap_cycle = [] # list of counts, one per cap cycle
# DL-collision. The diode rectifies the sum of every field on the
# antenna, so a competing reader is not filtered out - its carrier rides
# under the wanted reader's OOK and eats the modulation depth. The tag
# reads a query only if that reader beats the rest by dl_capture_db;
# otherwise it decodes nothing and goes silent, unable to report why.
# Sectors of one BS all radiate the same query, so their powers add into
# that BS's total. (If sectors ever send different queries, they become
# mutual interferers and this grouping no longer holds.)
# node.dl_ratio_db is refreshed every tick by calculate_node_power, so
# with fading on this is an event, not a fixed property of position.
self.dl_capture_db = dl_capture_db
self.action = env.process(self.run())
# ----------------------------
# FSM state handlers
# ----------------------------
def _enter_charge_and_discharge(self, i_amps: float):
"""Tell the capacitor to model a 1 ms step with simultaneous charging and load."""
self.capacitor_ctrl.current = i_amps
self.capacitor_ctrl.action.interrupt("capacitor_charge_and_discharge")
def _is_registered(self) -> bool:
"""Check if node is registered with BS (can send data)."""
if self.backscatter is None:
return True # No backscatter = always ready
return self.backscatter.state == "registered"
def working_mode(self):
"""
Working mode FSM: called when voltage above high threshold and active.
Handles the main duty cycle: listening -> sensing -> processing -> transmitting.
"""
c = self.p.currents
d = self.p.durations_ms
if self.state_name == "listening":
# Listen for incoming commands/data.
if self.counter == 0:
self.listening_start_ms = float(self.env.now)
self.counter += 1
if self.keep_logs:
self.acc_current_listening.append(c.listening)
self._enter_charge_and_discharge(c.listening)
if self.counter >= d.listening:
# If registered: sense data. If not: wait for slot to send ID
if self._is_registered():
self.state_name = "sensing"
else:
self.state_name = "wait_slot"
self.last_current = c.listening
self.counter = 0
elif self.state_name == "sensing":
# Sense data from the environment (only when registered).
if self.counter == 0:
self.sensing_start_ms = float(self.env.now)
self.counter += 1
if self.keep_logs:
self.acc_current_sensing.append(c.sensing)
self._enter_charge_and_discharge(c.sensing)
# produce a sensed value (stub)
self.sensed_value = random.randint(100, 255)
assert self.sensed_value != 0
if self.counter >= d.sensing:
self.state_name = "processing"
self.last_current = c.sensing
self.counter = 0
elif self.state_name == "processing":
# Process sensed data (only when registered).
if self.counter == 0:
self.processing_start_ms = float(self.env.now)
self.counter += 1
if self.keep_logs:
self.acc_current_processing.append(c.processing)
self._enter_charge_and_discharge(c.processing)
if self.counter >= d.processing:
self.state_name = "wait_slot"
self.last_current = c.processing
assert self.sensed_value != 0
self.processed_data = self.sensed_value
assert self.processed_data != 0
self.counter = 0
elif self.state_name == "wait_slot":
# Waiting for backscatter slot - charge while waiting
self.capacitor_ctrl.action.interrupt("capacitor_charging")
# Just registered but no data yet — go sense/process first
if self._is_registered() and self.processed_data == 0:
self.state_name = "sensing"
self.counter = 0
return
# Check if backscatter says we can transmit now
# (below capture: the query never got through, so nothing to answer)
if self.backscatter is not None and self.node.dl_ratio_db >= self.dl_capture_db:
tx_info = self.backscatter.controller_tx_ready(self.processed_data)
if tx_info is not None:
# Deliver at the exact validated in-slot instant (no 1-tick slip).
# controller_tx_ready() just confirmed `now` is inside the
# advertised RX slot, so the BS is listening right now. The
# `transmitting` state below only models the transmit energy
# draw over d.transmitting ms; the reception has already happened.
if self._is_registered() and self.keep_logs:
self.data_history.append(
(self.capacitor_ctrl.internal_time, self.processed_data)
)
self.backscatter.do_transmit(tx_info)
self.pending_tx_info = None
self.state_name = "transmitting"
self.counter = 0
elif self.state_name == "transmitting":
# Energy-draw phase only. The backscatter was already delivered at the
# validated in-slot instant in wait_slot; here we just model the
# transmit current over d.transmitting ms.
if self.counter == 0:
self.transmitting_start_ms = float(self.env.now)
self.counter += 1
if self.keep_logs:
self.acc_current_transmitting.append(c.transmitting)
self._enter_charge_and_discharge(c.transmitting)
if self.counter >= d.transmitting:
self.state_name = "listening"
self.last_current = c.transmitting
self.counter = 0
self.fsm_count_in_current_cap_cycle += 1
# ----------------------------
# Main process loop
# ----------------------------
def run(self):
thr = self.p.thresholds_v
while True:
self.node.state = self.state_name # for external visibility
# Recalculate harvesting power for this node every millisecond
if self.coverage_map is not None and self.downlink_results is not None:
self.coverage_map.calculate_node_power(
[self.node], self.downlink_results, now_ms=float(self.env.now)
)
# else wpt is tatic, no need to recalculate every ms
# Convert power (dBm) to voltage: V = sqrt(P * R)
power_w = dbm_to_watts(self.node.harvesting_power_dbm)
# Capacitor keeps this in .params, so a plain getattr for "R_series"
# never finds it and silently pins the conversion at 5 kOhm.
R_series = getattr(self.capacitor_ctrl.params, "R_series", 5e3)
self.capacitor_ctrl.voltage_source = (power_w * R_series) ** 0.5
# handle startup delay: charge during startup delay before becoming active
if self.capacitor_ctrl.internal_time <= self.startup_delay_s:
self.capacitor_ctrl.action.interrupt("capacitor_charging")
else:
v = float(getattr(self.capacitor_ctrl, "voltage", 0.0))
# transitions based on thresholds
if (v >= thr.high) and (self.is_active is False):
# capacitor charged enough: go active
self.capacitor_ctrl.action.interrupt("capacitor_full")
self.state_name = "listening"
self.counter = 0
self.is_active = True
elif self.is_active is False:
# still charging up (no controller current)
self.capacitor_ctrl.action.interrupt("capacitor_charging")
self.state_name = "listening" # reset to default state while charging
elif (v > thr.low) and (self.is_active is True):
# normal duty-cycling (charge_and_discharge handles both)
self.working_mode()
elif (v <= thr.low) and (self.is_active is True):
# idle - voltage too low, turn off and charge
self.is_active = False
if self.keep_logs:
self.fsm_counts_per_cap_cycle.append(self.fsm_count_in_current_cap_cycle)
self.fsm_count_in_current_cap_cycle = 0
self.capacitor_ctrl.action.interrupt("capacitor_charging")
yield self.env.timeout(self.p.tick_ms)
|